@inkline/inkline
Version:
Inkline is the Vue.js UI/UX Library built for creating your next design system
334 lines (304 loc) • 9.61 kB
text/typescript
/* eslint-disable no-case-declarations */
import { defineComponent } from 'vue';
import {
PopupMixin,
PopupControlsMixin,
sizePropValidator,
colorVariantClass,
defaultPropValue
} from '@inkline/inkline/mixins';
import { ClickOutside } from '@inkline/inkline/directives';
import { on, off, isFocusable, isKey } from '@inkline/inkline/helpers';
import { Classes } from '@inkline/inkline/types';
/**
* Slot for dropdown trigger
* @name default
* @kind slot
*/
/**
* Slot for dropdown header content
* @name header
* @kind slot
*/
/**
* Slot for dropdown body content
* @name body
* @kind slot
*/
/**
* Slot for dropdown footer content
* @name footer
* @kind slot
*/
const componentName = 'IDropdown';
export default defineComponent({
name: componentName,
directives: {
ClickOutside
},
mixins: [
PopupMixin,
PopupControlsMixin
],
provide () {
return {
dropdown: this
};
},
props: {
/**
* The duration of the hide and show animation
* @type Number
* @default 300
* @name animationDuration
*/
animationDuration: {
type: Number,
default: 300
},
/**
* The color variant of the dropdown
* @type light | dark
* @default light
* @name color
*/
color: {
type: String,
default: defaultPropValue<string>(componentName, 'color')
},
/**
* The disabled state of the dropdown
* @type Boolean
* @default false
* @name disabled
*/
disabled: {
type: Boolean,
default: false
},
/**
* Used to hide the dropdown when clicking or selecting a dropdown item
* @type Boolean
* @default false
* @name hideOnItemClick
*/
hideOnItemClick: {
type: Boolean,
default: true
},
/**
* The keydown events bound to the trigger element
* @type string[]
* @default [up, down, enter, space, tab, esc]
* @name keydownTrigger
*/
keydownTrigger: {
type: Array,
default: (): string[] => ['up', 'down', 'enter', 'space', 'tab', 'esc']
},
/**
* The keydown events bound to the dropdown item elements
* @type string[]
* @default [up, down, enter, space, tab, esc]
* @name keydownItem
*/
keydownItem: {
type: Array,
default: (): string[] => ['up', 'down', 'enter', 'space', 'tab', 'esc']
},
/**
* Used to manually control the visibility of the dropdown
* @type Boolean
* @default false
* @name modelValue
*/
modelValue: {
type: Boolean,
default: false
},
/**
* Displays an arrow on the dropdown pointing to the trigger element
* @type Boolean
* @default true
* @name arrow
*/
arrow: {
type: Boolean,
default: true
},
/**
* The placement of the dropdown
* @type top | top-start | top-end | bottom | bottom-start | bottom-end | left | left-start | left-end | right | right-start | right-end
* @default false
* @name placement
*/
placement: {
type: String,
default: 'bottom'
},
/**
* The events used to trigger the dropdown
* @type hover | focus | click | manual
* @default [click]
* @name trigger
*/
trigger: {
type: [String, Array],
default: (): string[] => ['click']
},
/**
* The offset of the dropdown relative to the trigger element
* @type Number
* @default 6
* @name offset
*/
offset: {
type: Number,
default: 6
},
/**
* Used to override the popper.js options used for creating the dropdown
* @type Object
* @default {}
* @name popperOptions
*/
popperOptions: {
type: Object,
default: (): any => ({})
},
/**
* The size variant of the dropdown
* @type sm | md | lg
* @default md
* @name size
*/
size: {
type: String,
default: defaultPropValue<string>(componentName, 'size'),
validator: sizePropValidator
}
},
emits: [
/**
* Event emitted for setting the modelValue
* @event update:modelValue
*/
'update:modelValue'
],
computed: {
classes (): Classes {
return {
...colorVariantClass(this),
[`-${this.size}`]: Boolean(this.size)
};
}
},
mounted () {
for (const child of (this.$refs.trigger as HTMLElement).children) {
on(child as HTMLElement, 'keydown', this.onTriggerKeyDown);
}
on(this.$refs.popup as HTMLElement, 'keydown', this.onItemKeyDown);
},
beforeUnmount () {
for (const child of (this.$refs.trigger as HTMLElement).children) {
off(child as HTMLElement, 'keydown', this.onTriggerKeyDown);
}
off(this.$refs.popup as HTMLElement, 'keydown', this.onItemKeyDown);
},
methods: {
onEscape () {
this.visible = false;
this.$emit('update:modelValue', false);
},
handleClickOutside () {
this.visible = false;
this.$emit('update:modelValue', false);
this.onClickOutside();
},
getFocusableItems (): HTMLElement[] {
const focusableItems = [];
for (const child of (this.$refs.body as HTMLElement).children) {
if (isFocusable(child as HTMLElement)) {
focusableItems.push(child as HTMLElement);
}
}
return focusableItems;
},
onTriggerKeyDown (event: KeyboardEvent) {
if (this.keydownTrigger.length === 0) {
return;
}
const focusableItems = this.getFocusableItems();
const activeIndex = focusableItems.findIndex((item: any) => item.active);
const initialIndex = activeIndex > -1 ? activeIndex : 0;
const focusTarget = focusableItems[initialIndex];
switch (true) {
case isKey('up', event) && this.keydownTrigger.includes('up'):
case isKey('down', event) && this.keydownTrigger.includes('down'):
this.show();
setTimeout(() => {
focusTarget.focus();
}, this.visible ? 0 : this.animationDuration);
event.preventDefault();
event.stopPropagation();
break;
case isKey('enter', event) && this.keydownTrigger.includes('enter'):
case isKey('space', event) && this.keydownTrigger.includes('space'):
this.onClick();
if (!this.visible) {
setTimeout(() => {
focusTarget.focus();
}, this.animationDuration);
}
event.preventDefault();
break;
case isKey('tab', event) && this.keydownTrigger.includes('tab'):
case isKey('esc', event) && this.keydownTrigger.includes('esc'):
this.hide();
break;
}
},
onItemKeyDown (event: KeyboardEvent) {
if (this.keydownItem.length === 0) {
return;
}
switch (true) {
case isKey('up', event) && this.keydownItem.includes('up'):
case isKey('down', event) && this.keydownItem.includes('down'):
const focusableItems = this.getFocusableItems();
const currentIndex = focusableItems.findIndex((item) => item === event.target);
const maxIndex = focusableItems.length - 1;
let nextIndex;
if (isKey('up', event)) {
nextIndex = currentIndex > 0 ? currentIndex - 1 : 0;
} else {
nextIndex = currentIndex < maxIndex ? currentIndex + 1 : maxIndex;
}
focusableItems[nextIndex].focus();
event.preventDefault();
event.stopPropagation();
break;
case isKey('enter', event) && this.keydownItem.includes('enter'):
case isKey('space', event) && this.keydownItem.includes('space'):
(event as any).target.click();
if (this.hideOnItemClick) {
this.hide();
}
this.focusTrigger();
event.preventDefault();
break;
case isKey('tab', event) && this.keydownItem.includes('tab'):
case isKey('esc', event) && this.keydownItem.includes('esc'):
this.hide();
this.focusTrigger();
event.preventDefault();
break;
}
},
onItemClick () {
if (this.hideOnItemClick) {
this.hide();
}
}
}
});