reblend-ui
Version:
Utilities for creating robust overlay components
247 lines (244 loc) • 9.94 kB
JavaScript
"use strict";
exports.__esModule = true;
exports.default = void 0;
var _querySelectorAll = require("dom-helpers/querySelectorAll");
var _addEventListener = require("dom-helpers/addEventListener");
var _reblendjs = require("reblendjs");
var _reblendHooks = require("reblend-hooks");
var _DropdownContext = require("./DropdownContext");
var _DropdownMenu = require("./DropdownMenu");
var _DropdownToggle = require("./DropdownToggle");
var _DropdownItem = require("./DropdownItem");
var _SelectableContext = require("./SelectableContext");
var _DataKey = require("./DataKey");
var _useWindow = require("./useWindow");
function useRefWithUpdate(
/* @Reblend: Transformed from function to class */
) {
const forceUpdate = _reblendHooks.useForceUpdate.bind(this)();
this.state.forceUpdate = forceUpdate;
const ref = _reblendjs.useRef.bind(this)(null);
this.state.ref = ref;
const attachRef = _reblendjs.useCallback.bind(this)(element => {
this.state.ref.current = element;
// ensure that a menu set triggers an update for consumers
this.state.forceUpdate();
});
this.state.attachRef = attachRef;
return [this.state.ref, this.state.attachRef];
}
/**
* @displayName Dropdown
* @public
*/
class Dropdown extends _reblendjs.default {
static ELEMENT_NAME = "Dropdown";
constructor() {
super();
}
async initState() {
const [window] = _useWindow.default.bind(this)();
this.state.window = window;
const [show, setShow] = _reblendjs.useState.bind(this)(this.props.defaultShow, "show");
this.state.show = show;
this.state.setShow = setShow;
_reblendjs.useEffect.bind(this)(() => {
if (typeof this.props.rawShow !== 'undefined') {
this.state.setShow(this.props.rawShow);
}
}, (() => this.props.rawShow).bind(this));
const onToggle = _reblendjs.useCallback.bind(this)((nextShow, meta) => {
if (typeof this.props.rawShow !== 'undefined') {
// controlled, so we don't update the state
return this.props.rawOnToggle?.(nextShow, meta);
} else if (nextShow !== this.state.show) {
// uncontrolled, so we update the state
this.state.setShow(nextShow);
this.props.rawOnToggle?.(nextShow, meta);
}
});
// We use normal refs instead of useCallbackRef in order to populate the
// the value as quickly as possible, otherwise the effect to focus the element
// may run before the state value is set
this.state.onToggle = onToggle;
const [menuRef, setMenu] = useRefWithUpdate.bind(this)();
this.state.menuRef = menuRef;
this.state.setMenu = setMenu;
const [toggleRef, setToggle] = useRefWithUpdate.bind(this)();
this.state.toggleRef = toggleRef;
this.state.setToggle = setToggle;
const lastShow = _reblendjs.useMemo.bind(this)(({
previous
}) => previous, "lastShow", (() => this.state.show).bind(this));
this.state.lastShow = lastShow;
const lastSourceEvent = _reblendjs.useRef.bind(this)(null);
this.state.lastSourceEvent = lastSourceEvent;
const focusInDropdown = _reblendjs.useRef.bind(this)(false);
this.state.focusInDropdown = focusInDropdown;
const [onSelectCtx] = _reblendjs.useContext.bind(this)(_SelectableContext.default, "onSelectCtx");
this.state.onSelectCtx = onSelectCtx;
const toggle = _reblendjs.useCallback.bind(this)((nextShow, event, source = event?.type) => {
this.state.onToggle(nextShow, {
originalEvent: event,
source
});
});
this.state.toggle = toggle;
const handleSelect = _reblendHooks.useEventCallback.bind(this)((key, event) => {
this.props.onSelect?.(key, event);
this.state.toggle(false, event, 'select');
/* if (!event.isPropagationStopped()) {
this.state.onSelectCtx?.(key, event);
} */
});
this.state.handleSelect = handleSelect;
_reblendjs.useEffect.bind(this)(() => {
const context = {
toggle: this.state.toggle,
placement: this.props.placement,
show: !!this.state.show,
menuElement: this.state.menuRef.current,
toggleElement: this.state.toggleRef.current,
setMenu: this.state.setMenu,
setToggle: this.state.setToggle
};
_DropdownContext.default.update(context);
}, (() => [this.props.placement, this.state.show, this.state.menuRef.current, this.state.toggleRef.current, this.state.setMenu, this.state.setToggle]).bind(this));
_reblendjs.useEffect.bind(this)(() => {
if (this.state.menuRef.current && this.state.lastShow && !this.state.show) {
this.state.focusInDropdown.current = this.state.menuRef.current.contains(this.state.menuRef.current.ownerDocument.activeElement);
}
});
const focusToggle = _reblendHooks.useEventCallback.bind(this)(() => {
if (this.state.toggleRef.current && this.state.toggleRef.current.focus) {
this.state.toggleRef.current.focus();
}
});
this.state.focusToggle = focusToggle;
const maybeFocusFirst = _reblendHooks.useEventCallback.bind(this)(() => {
const type = this.state.lastSourceEvent.current;
let focusType = this.props.focusFirstItemOnShow;
if (focusType == null) {
focusType = this.state.menuRef.current && (0, _DropdownToggle.isRoleMenu)(this.state.menuRef.current) ? 'keyboard' : false;
}
if (focusType === false || focusType === 'keyboard' && !/^key.+$/.test(type)) {
return;
}
const first = (0, _querySelectorAll.default)(this.state.menuRef.current, this.props.itemSelector)[0];
if (first && first.focus) first.focus();
});
this.state.maybeFocusFirst = maybeFocusFirst;
_reblendjs.useEffect.bind(this)(() => {
if (this.state.show) this.state.maybeFocusFirst();else if (this.state.focusInDropdown.current) {
this.state.focusInDropdown.current = false;
this.state.focusToggle();
}
// only `show` should be changing
}, (() => [this.state.show, this.state.focusInDropdown]).bind(this));
_reblendjs.useEffect.bind(this)(() => {
this.state.lastSourceEvent.current = null;
});
const getNextFocusedChild = (current, offset) => {
if (!this.state.menuRef.current) return null;
const items = (0, _querySelectorAll.default)(this.state.menuRef.current, this.props.itemSelector);
let index = items.indexOf(current) + offset;
index = Math.max(0, Math.min(index, items.length));
return items[index];
};
this.state.getNextFocusedChild = getNextFocusedChild;
_reblendHooks.useEventListener.bind(this)(_reblendjs.useCallback.bind(this)(() => this.state.window.document), 'keydown', event => {
const {
key
} = event;
const target = event.target;
const fromMenu = this.state.menuRef.current?.contains(target);
const fromToggle = this.state.toggleRef.current?.contains(target);
// Second only to https://github.com/twbs/bootstrap/blob/8cfbf6933b8a0146ac3fbc369f19e520bd1ebdac/js/src/dropdown.js#L400
// in inscrutability
const isInput = /input|textarea/i.test(target.tagName);
if (isInput && (key === ' ' || key !== 'Escape' && fromMenu || key === 'Escape' && target.type === 'search')) {
return;
}
if (!fromMenu && !fromToggle) {
return;
}
if (key === 'Tab' && (!this.state.menuRef.current || !this.state.show)) {
return;
}
this.state.lastSourceEvent.current = event.type;
const meta = {
originalEvent: event,
source: event.type
};
switch (key) {
case 'ArrowUp':
{
const next = this.state.getNextFocusedChild(target, -1);
if (next && next.focus) next.focus();
event.preventDefault();
return;
}
case 'ArrowDown':
event.preventDefault();
if (!this.state.show) {
this.state.onToggle(true, meta);
} else {
const next = this.state.getNextFocusedChild(target, 1);
if (next && next.focus) next.focus();
}
return;
case 'Tab':
// on keydown the target is the element being tabbed FROM, we need that
// to know if this event is relevant to this dropdown (e.g. in this menu).
// On `keyup` the target is the element being tagged TO which we use to check
// if focus has left the menu
(0, _addEventListener.default)(target.ownerDocument, 'keyup', e => {
if (e.key === 'Tab' && !e.target || !this.state.menuRef.current?.contains(e.target)) {
this.state.onToggle(false, meta);
}
}, {
once: true
});
break;
case 'Escape':
if (key === 'Escape') {
event.preventDefault();
event.stopPropagation();
}
this.state.onToggle(false, meta);
break;
default:
}
});
_SelectableContext.default.update(this.state.handleSelect);
}
async initProps({
defaultShow,
show: rawShow,
onSelect,
onToggle: rawOnToggle,
itemSelector = `* [${(0, _DataKey.dataAttr)('dropdown-item')}]`,
focusFirstItemOnShow,
placement = 'bottom-start',
children
}) {
this.props = {};
this.props.defaultShow = defaultShow;
this.props.rawShow = rawShow;
this.props.onSelect = onSelect;
this.props.rawOnToggle = rawOnToggle;
this.props.itemSelector = itemSelector;
this.props.focusFirstItemOnShow = focusFirstItemOnShow;
this.props.placement = placement;
this.props.children = children;
}
async html() {
return this.props.children;
}
}
/* @Reblend: Transformed from function to class */
Dropdown.displayName = 'Dropdown';
Dropdown.Menu = _DropdownMenu.default;
Dropdown.Toggle = _DropdownToggle.default;
Dropdown.Item = _DropdownItem.default;
var _default = exports.default = Dropdown;