chore: convert Dropdown components to TS (#3608)

* chore: convert `Dropdown` components to TS
* chore(review): `buttonClassName` technically not required
* chore(review): `accessibleToggleLabel` technically not required
* chore(review): use `classList` where possible
* chore: `yarn format`
* Update framework/core/js/src/common/components/Dropdown.tsx
* chore(review): use `includes`
* chore(review): define constant of excluded groups
* chore(review): use `null coalesce` and `logical or` assignments
* chore(review): `null coalesce`
* chore(review): `any` to `typeof Component`
* chore(review): `classList`
* chore(review): `yarn format`
* chore: fix typing issues after typescript update

Signed-off-by: Sami Mazouz <sychocouldy@gmail.com>
Co-authored-by: David Wheatley <hi@davwheat.dev>
This commit is contained in:
Sami Mazouz 2022-11-27 10:34:29 +01:00 committed by GitHub
parent 0e238a9c82
commit 5bc47c0278
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 368 additions and 322 deletions

View File

@ -40,6 +40,7 @@ export interface AdminApplicationData extends ApplicationData {
modelStatistics: Record<string, { total: number }>;
displayNameDrivers: string[];
slugDrivers: Record<string, string[]>;
permissions: Record<string, string[]>;
}
export default class AdminApplication extends Application {

View File

@ -1,18 +1,19 @@
import app from '../../admin/app';
import Dropdown from '../../common/components/Dropdown';
import Dropdown, { IDropdownAttrs } from '../../common/components/Dropdown';
import Button from '../../common/components/Button';
import Separator from '../../common/components/Separator';
import Group from '../../common/models/Group';
import Badge from '../../common/components/Badge';
import GroupBadge from '../../common/components/GroupBadge';
import Mithril from 'mithril';
function badgeForId(id) {
function badgeForId(id: string) {
const group = app.store.getById('groups', id);
return group ? GroupBadge.component({ group, label: null }) : '';
}
function filterByRequiredPermissions(groupIds, permission) {
function filterByRequiredPermissions(groupIds: string[], permission: string) {
app.getRequiredPermissions(permission).forEach((required) => {
const restrictToGroupIds = app.data.permissions[required] || [];
@ -32,24 +33,28 @@ function filterByRequiredPermissions(groupIds, permission) {
return groupIds;
}
export default class PermissionDropdown extends Dropdown {
static initAttrs(attrs) {
export interface IPermissionDropdownAttrs extends IDropdownAttrs {
permission: string;
}
export default class PermissionDropdown<CustomAttrs extends IPermissionDropdownAttrs = IPermissionDropdownAttrs> extends Dropdown<CustomAttrs> {
static initAttrs(attrs: IPermissionDropdownAttrs) {
super.initAttrs(attrs);
attrs.className = 'PermissionDropdown';
attrs.buttonClassName = 'Button Button--text';
}
view(vnode) {
view(vnode: Mithril.Vnode<CustomAttrs, this>) {
const children = [];
let groupIds = app.data.permissions[this.attrs.permission] || [];
groupIds = filterByRequiredPermissions(groupIds, this.attrs.permission);
const everyone = groupIds.indexOf(Group.GUEST_ID) !== -1;
const members = groupIds.indexOf(Group.MEMBER_ID) !== -1;
const adminGroup = app.store.getById('groups', Group.ADMINISTRATOR_ID);
const everyone = groupIds.includes(Group.GUEST_ID);
const members = groupIds.includes(Group.MEMBER_ID);
const adminGroup = app.store.getById<Group>('groups', Group.ADMINISTRATOR_ID)!;
if (everyone) {
this.attrs.label = Badge.component({ icon: 'fas fa-globe' });
@ -89,40 +94,42 @@ export default class PermissionDropdown extends Dropdown {
{
icon: !everyone && !members ? 'fas fa-check' : true,
disabled: !everyone && !members,
onclick: (e) => {
onclick: (e: MouseEvent) => {
if (e.shiftKey) e.stopPropagation();
this.save([]);
},
},
[badgeForId(adminGroup.id()), ' ', adminGroup.namePlural()]
[badgeForId(adminGroup.id()!), ' ', adminGroup.namePlural()]
)
);
[].push.apply(
children,
app.store
.all('groups')
.filter((group) => [Group.ADMINISTRATOR_ID, Group.GUEST_ID, Group.MEMBER_ID].indexOf(group.id()) === -1)
.map((group) =>
Button.component(
{
icon: groupIds.indexOf(group.id()) !== -1 ? 'fas fa-check' : true,
onclick: (e) => {
if (e.shiftKey) e.stopPropagation();
this.toggle(group.id());
},
disabled: this.isGroupDisabled(group.id()) && this.isGroupDisabled(Group.MEMBER_ID) && this.isGroupDisabled(Group.GUEST_ID),
// These groups are defined above, appearing first in the list.
const excludedGroups = [Group.ADMINISTRATOR_ID, Group.GUEST_ID, Group.MEMBER_ID];
const groupButtons = app.store
.all<Group>('groups')
.filter((group) => !excludedGroups.includes(group.id()!))
.map((group) =>
Button.component(
{
icon: groupIds.includes(group.id()!) ? 'fas fa-check' : true,
onclick: (e: MouseEvent) => {
if (e.shiftKey) e.stopPropagation();
this.toggle(group.id()!);
},
[badgeForId(group.id()), ' ', group.namePlural()]
)
disabled: this.isGroupDisabled(group.id()!) && this.isGroupDisabled(Group.MEMBER_ID) && this.isGroupDisabled(Group.GUEST_ID),
},
[badgeForId(group.id()!), ' ', group.namePlural()]
)
);
);
children.push(...groupButtons);
}
return super.view({ ...vnode, children });
}
save(groupIds) {
save(groupIds: string[]) {
const permission = this.attrs.permission;
app.data.permissions[permission] = groupIds;
@ -134,7 +141,7 @@ export default class PermissionDropdown extends Dropdown {
});
}
toggle(groupId) {
toggle(groupId: string) {
const permission = this.attrs.permission;
let groupIds = app.data.permissions[permission] || [];
@ -151,7 +158,7 @@ export default class PermissionDropdown extends Dropdown {
this.save(groupIds);
}
isGroupDisabled(id) {
return filterByRequiredPermissions([id], this.attrs.permission).indexOf(id) === -1;
isGroupDisabled(id: string) {
return !filterByRequiredPermissions([id], this.attrs.permission).includes(id);
}
}

View File

@ -1,16 +1,19 @@
import app from '../../admin/app';
import avatar from '../../common/helpers/avatar';
import username from '../../common/helpers/username';
import Dropdown from '../../common/components/Dropdown';
import Dropdown, { IDropdownAttrs } from '../../common/components/Dropdown';
import Button from '../../common/components/Button';
import ItemList from '../../common/utils/ItemList';
import type Mithril from 'mithril';
export interface ISessionDropdownAttrs extends IDropdownAttrs {}
/**
* The `SessionDropdown` component shows a button with the current user's
* avatar/name, with a dropdown of session controls.
*/
export default class SessionDropdown extends Dropdown {
static initAttrs(attrs) {
export default class SessionDropdown<CustomAttrs extends ISessionDropdownAttrs = ISessionDropdownAttrs> extends Dropdown<CustomAttrs> {
static initAttrs(attrs: ISessionDropdownAttrs) {
super.initAttrs(attrs);
attrs.className = 'SessionDropdown';
@ -18,7 +21,7 @@ export default class SessionDropdown extends Dropdown {
attrs.menuClassName = 'Dropdown-menu--right';
}
view(vnode) {
view(vnode: Mithril.Vnode<CustomAttrs, this>) {
return super.view({ ...vnode, children: this.items().toArray() });
}
@ -30,11 +33,9 @@ export default class SessionDropdown extends Dropdown {
/**
* Build an item list for the contents of the dropdown menu.
*
* @return {ItemList}
*/
items() {
const items = new ItemList();
items(): ItemList<Mithril.Children> {
const items = new ItemList<Mithril.Children>();
items.add(
'logOut',

View File

@ -1,10 +1,21 @@
import app from '../app';
import SelectDropdown from '../../common/components/SelectDropdown';
import SelectDropdown, { ISelectDropdownAttrs } from '../../common/components/SelectDropdown';
import Button from '../../common/components/Button';
import saveSettings from '../utils/saveSettings';
import Mithril from 'mithril';
export default class SettingDropdown extends SelectDropdown {
static initAttrs(attrs) {
export type SettingDropdownOption = {
value: any;
label: string;
};
export interface ISettingDropdownAttrs extends ISelectDropdownAttrs {
setting?: string;
options: Array<SettingDropdownOption>;
}
export default class SettingDropdown<CustomAttrs extends ISettingDropdownAttrs = ISettingDropdownAttrs> extends SelectDropdown<CustomAttrs> {
static initAttrs(attrs: ISettingDropdownAttrs) {
super.initAttrs(attrs);
attrs.className = 'SettingDropdown';
@ -13,21 +24,21 @@ export default class SettingDropdown extends SelectDropdown {
attrs.defaultLabel = 'Custom';
if ('key' in attrs) {
attrs.setting = attrs.key;
attrs.setting = attrs.key?.toString();
delete attrs.key;
}
}
view(vnode) {
view(vnode: Mithril.Vnode<CustomAttrs, this>) {
return super.view({
...vnode,
children: this.attrs.options.map(({ value, label }) => {
const active = app.data.settings[this.attrs.setting] === value;
const active = app.data.settings[this.attrs.setting!] === value;
return Button.component(
{
icon: active ? 'fas fa-check' : true,
onclick: saveSettings.bind(this, { [this.attrs.setting]: value }),
onclick: saveSettings.bind(this, { [this.attrs.setting!]: value }),
active,
},
label

View File

@ -1,140 +0,0 @@
import app from '../../common/app';
import Component from '../Component';
import icon from '../helpers/icon';
import listItems from '../helpers/listItems';
/**
* The `Dropdown` component displays a button which, when clicked, shows a
* dropdown menu beneath it.
*
* ### Attrs
*
* - `buttonClassName` A class name to apply to the dropdown toggle button.
* - `menuClassName` A class name to apply to the dropdown menu.
* - `icon` The name of an icon to show in the dropdown toggle button.
* - `caretIcon` The name of an icon to show on the right of the button.
* - `label` The label of the dropdown toggle button. Defaults to 'Controls'.
* - `accessibleToggleLabel` The label used to describe the dropdown toggle button to assistive readers. Defaults to 'Toggle dropdown menu'.
* - `onhide`
* - `onshow`
*
* The children will be displayed as a list inside of the dropdown menu.
*/
export default class Dropdown extends Component {
static initAttrs(attrs) {
attrs.className = attrs.className || '';
attrs.buttonClassName = attrs.buttonClassName || '';
attrs.menuClassName = attrs.menuClassName || '';
attrs.label = attrs.label || '';
attrs.caretIcon = typeof attrs.caretIcon !== 'undefined' ? attrs.caretIcon : 'fas fa-caret-down';
attrs.accessibleToggleLabel = attrs.accessibleToggleLabel || app.translator.trans('core.lib.dropdown.toggle_dropdown_accessible_label');
}
oninit(vnode) {
super.oninit(vnode);
this.showing = false;
}
view(vnode) {
const items = vnode.children ? listItems(vnode.children) : [];
const renderItems = this.attrs.lazyDraw ? this.showing : true;
return (
<div className={'ButtonGroup Dropdown dropdown ' + this.attrs.className + ' itemCount' + items.length + (this.showing ? ' open' : '')}>
{this.getButton(vnode.children)}
{renderItems && this.getMenu(items)}
</div>
);
}
oncreate(vnode) {
super.oncreate(vnode);
// When opening the dropdown menu, work out if the menu goes beyond the
// bottom of the viewport. If it does, we will apply class to make it show
// above the toggle button instead of below it.
this.$().on('shown.bs.dropdown', () => {
const { lazyDraw, onshow } = this.attrs;
this.showing = true;
// If using lazy drawing, redraw before calling `onshow` function
// to make sure the menu DOM exists in case the callback tries to use it.
if (lazyDraw) {
m.redraw.sync();
}
if (typeof onshow === 'function') {
onshow();
}
// If not using lazy drawing, keep previous functionality
// of redrawing after calling onshow()
if (!lazyDraw) {
m.redraw();
}
const $menu = this.$('.Dropdown-menu');
const isRight = $menu.hasClass('Dropdown-menu--right');
$menu.removeClass('Dropdown-menu--top Dropdown-menu--right');
$menu.toggleClass('Dropdown-menu--top', $menu.offset().top + $menu.height() > $(window).scrollTop() + $(window).height());
if ($menu.offset().top < 0) {
$menu.removeClass('Dropdown-menu--top');
}
$menu.toggleClass('Dropdown-menu--right', isRight || $menu.offset().left + $menu.width() > $(window).scrollLeft() + $(window).width());
});
this.$().on('hidden.bs.dropdown', () => {
this.showing = false;
if (this.attrs.onhide) {
this.attrs.onhide();
}
m.redraw();
});
}
/**
* Get the template for the button.
*
* @return {import('mithril').Children}
* @protected
*/
getButton(children) {
return (
<button
className={'Dropdown-toggle ' + this.attrs.buttonClassName}
aria-haspopup="menu"
aria-label={this.attrs.accessibleToggleLabel}
data-toggle="dropdown"
onclick={this.attrs.onclick}
>
{this.getButtonContent(children)}
</button>
);
}
/**
* Get the template for the button's content.
*
* @return {import('mithril').Children}
* @protected
*/
getButtonContent(children) {
return [
this.attrs.icon ? icon(this.attrs.icon, { className: 'Button-icon' }) : '',
<span className="Button-label">{this.attrs.label}</span>,
this.attrs.caretIcon ? icon(this.attrs.caretIcon, { className: 'Button-caret' }) : '',
];
}
getMenu(items) {
return <ul className={'Dropdown-menu dropdown-menu ' + this.attrs.menuClassName}>{items}</ul>;
}
}

View File

@ -0,0 +1,152 @@
import app from '../../common/app';
import Component, { ComponentAttrs } from '../Component';
import icon from '../helpers/icon';
import listItems, { ModdedChildrenWithItemName } from '../helpers/listItems';
import extractText from '../utils/extractText';
import type Mithril from 'mithril';
export interface IDropdownAttrs extends ComponentAttrs {
/** A class name to apply to the dropdown toggle button. */
buttonClassName?: string;
/** A class name to apply to the dropdown menu. */
menuClassName?: string;
/** The name of an icon to show in the dropdown toggle button. */
icon?: string;
/** The name of an icon to show on the right of the button. */
caretIcon?: string;
/** The label of the dropdown toggle button. Defaults to 'Controls'. */
label: Mithril.Children;
/** The label used to describe the dropdown toggle button to assistive readers. Defaults to 'Toggle dropdown menu'. */
accessibleToggleLabel?: string;
/** An action to take when the dropdown is collapsed. */
onhide?: () => void;
/** An action to take when the dropdown is opened. */
onshow?: () => void;
lazyDraw?: boolean;
}
/**
* The `Dropdown` component displays a button which, when clicked, shows a
* dropdown menu beneath it.
*
* The children will be displayed as a list inside the dropdown menu.
*/
export default class Dropdown<CustomAttrs extends IDropdownAttrs = IDropdownAttrs> extends Component<CustomAttrs> {
protected showing = false;
static initAttrs(attrs: IDropdownAttrs) {
attrs.className ||= '';
attrs.buttonClassName ||= '';
attrs.menuClassName ||= '';
attrs.label ||= '';
attrs.caretIcon ??= 'fas fa-caret-down';
attrs.accessibleToggleLabel ||= extractText(app.translator.trans('core.lib.dropdown.toggle_dropdown_accessible_label'));
}
view(vnode: Mithril.Vnode<CustomAttrs, this>) {
const items = vnode.children ? listItems(vnode.children as ModdedChildrenWithItemName[]) : [];
const renderItems = this.attrs.lazyDraw ? this.showing : true;
return (
<div className={'ButtonGroup Dropdown dropdown ' + this.attrs.className + ' itemCount' + items.length + (this.showing ? ' open' : '')}>
{this.getButton(vnode.children as Mithril.ChildArray)}
{renderItems && this.getMenu(items)}
</div>
);
}
oncreate(vnode: Mithril.VnodeDOM<CustomAttrs, this>) {
super.oncreate(vnode);
// When opening the dropdown menu, work out if the menu goes beyond the
// bottom of the viewport. If it does, we will apply class to make it show
// above the toggle button instead of below it.
this.$().on('shown.bs.dropdown', () => {
const { lazyDraw, onshow } = this.attrs;
this.showing = true;
// If using lazy drawing, redraw before calling `onshow` function
// to make sure the menu DOM exists in case the callback tries to use it.
if (lazyDraw) {
m.redraw.sync();
}
if (typeof onshow === 'function') {
onshow();
}
// If not using lazy drawing, keep previous functionality
// of redrawing after calling onshow()
if (!lazyDraw) {
m.redraw();
}
const $menu = this.$('.Dropdown-menu');
const isRight = $menu.hasClass('Dropdown-menu--right');
const top = $menu.offset()?.top ?? 0;
const height = $menu.height() ?? 0;
const windowSrollTop = $(window).scrollTop() ?? 0;
const windowHeight = $(window).height() ?? 0;
$menu.removeClass('Dropdown-menu--top Dropdown-menu--right');
$menu.toggleClass('Dropdown-menu--top', top + height > windowSrollTop + windowHeight);
if (($menu.offset()?.top || 0) < 0) {
$menu.removeClass('Dropdown-menu--top');
}
const left = $menu.offset()?.left ?? 0;
const width = $menu.width() ?? 0;
const windowScrollLeft = $(window).scrollLeft() ?? 0;
const windowWidth = $(window).width() ?? 0;
$menu.toggleClass('Dropdown-menu--right', isRight || left + width > windowScrollLeft + windowWidth);
});
this.$().on('hidden.bs.dropdown', () => {
this.showing = false;
if (this.attrs.onhide) {
this.attrs.onhide();
}
m.redraw();
});
}
/**
* Get the template for the button.
*/
getButton(children: Mithril.ChildArray): Mithril.Vnode<any, any> {
return (
<button
className={'Dropdown-toggle ' + this.attrs.buttonClassName}
aria-haspopup="menu"
aria-label={this.attrs.accessibleToggleLabel}
data-toggle="dropdown"
onclick={this.attrs.onclick}
>
{this.getButtonContent(children)}
</button>
);
}
/**
* Get the template for the button's content.
*/
getButtonContent(children: Mithril.ChildArray): Mithril.ChildArray {
return [
this.attrs.icon ? icon(this.attrs.icon, { className: 'Button-icon' }) : '',
<span className="Button-label">{this.attrs.label}</span>,
this.attrs.caretIcon ? icon(this.attrs.caretIcon, { className: 'Button-caret' }) : '',
];
}
getMenu(items: Mithril.Vnode<any, any>[]): Mithril.Vnode<any, any> {
return <ul className={'Dropdown-menu dropdown-menu ' + this.attrs.menuClassName}>{items}</ul>;
}
}

View File

@ -47,7 +47,7 @@ export default class Navigation extends Component {
icon: 'fas fa-chevron-left',
'aria-label': previous?.title,
onclick: (e: MouseEvent) => {
if (e.shiftKey || e.ctrlKey || e.metaKey || e.which === 2) return;
if (e.shiftKey || e.ctrlKey || e.metaKey || e.button === 1) return;
e.preventDefault();
history?.back();
},

View File

@ -1,52 +0,0 @@
import Dropdown from './Dropdown';
import icon from '../helpers/icon';
/**
* Determines via a vnode is currently "active".
* Due to changes in Mithril 2, attrs will not be instantiated until AFTER view()
* is initially called on the parent component, so we can not always depend on the
* active attr to determine which element should be displayed as the "active child".
*
* This is a temporary patch, and as so, is not exported / placed in utils.
*/
function isActive(vnode) {
const tag = vnode.tag;
// Allow non-selectable dividers/headers to be added.
if (typeof tag === 'string' && tag !== 'a' && tag !== 'button') return false;
if ('initAttrs' in tag) {
tag.initAttrs(vnode.attrs);
}
return 'isActive' in tag ? tag.isActive(vnode.attrs) : vnode.attrs.active;
}
/**
* The `SelectDropdown` component is the same as a `Dropdown`, except the toggle
* button's label is set as the label of the first child which has a truthy
* `active` prop.
*
* ### Attrs
*
* - `caretIcon`
* - `defaultLabel`
*/
export default class SelectDropdown extends Dropdown {
static initAttrs(attrs) {
attrs.caretIcon = typeof attrs.caretIcon !== 'undefined' ? attrs.caretIcon : 'fas fa-sort';
super.initAttrs(attrs);
attrs.className += ' Dropdown--select';
}
getButtonContent(children) {
const activeChild = children.find(isActive);
let label = (activeChild && activeChild.children) || this.attrs.defaultLabel;
if (label instanceof Array) label = label[0];
return [<span className="Button-label">{label}</span>, icon(this.attrs.caretIcon, { className: 'Button-caret' })];
}
}

View File

@ -0,0 +1,57 @@
import Dropdown, { IDropdownAttrs } from './Dropdown';
import icon from '../helpers/icon';
import extractText from '../utils/extractText';
import classList from '../utils/classList';
import type Component from '../Component';
import type Mithril from 'mithril';
/**
* Determines via a vnode is currently "active".
* Due to changes in Mithril 2, attrs will not be instantiated until AFTER view()
* is initially called on the parent component, so we can not always depend on the
* active attr to determine which element should be displayed as the "active child".
*
* This is a temporary patch, and as so, is not exported / placed in utils.
*/
function isActive(vnode: Mithril.Children): boolean {
if (!vnode || typeof vnode !== 'object' || vnode instanceof Array) return false;
const tag = vnode.tag;
// Allow non-selectable dividers/headers to be added.
if (typeof tag === 'string' && tag !== 'a' && tag !== 'button') return false;
if (typeof tag === 'object' && 'initAttrs' in tag) {
(tag as unknown as typeof Component).initAttrs(vnode.attrs);
}
return typeof tag === 'object' && 'isActive' in tag ? (tag as any).isActive(vnode.attrs) : vnode.attrs.active;
}
export interface ISelectDropdownAttrs extends IDropdownAttrs {
defaultLabel: string;
}
/**
* The `SelectDropdown` component is the same as a `Dropdown`, except the toggle
* button's label is set as the label of the first child which has a truthy
* `active` prop.
*/
export default class SelectDropdown<CustomAttrs extends ISelectDropdownAttrs = ISelectDropdownAttrs> extends Dropdown<CustomAttrs> {
static initAttrs(attrs: ISelectDropdownAttrs) {
attrs.caretIcon ??= 'fas fa-sort';
super.initAttrs(attrs);
attrs.className = classList(attrs.className, 'Dropdown--select');
}
getButtonContent(children: Mithril.ChildArray): Mithril.ChildArray {
const activeChild = children.find(isActive);
let label = (activeChild && typeof activeChild === 'object' && 'children' in activeChild && activeChild.children) || this.attrs.defaultLabel;
label = extractText(label);
return [<span className="Button-label">{label}</span>, this.attrs.caretIcon ? icon(this.attrs.caretIcon, { className: 'Button-caret' }) : null];
}
}

View File

@ -1,54 +0,0 @@
import Dropdown from './Dropdown';
import Button from './Button';
import icon from '../helpers/icon';
/**
* The `SplitDropdown` component is similar to `Dropdown`, but the first child
* is displayed as its own button prior to the toggle button.
*/
export default class SplitDropdown extends Dropdown {
static initAttrs(attrs) {
super.initAttrs(attrs);
attrs.className += ' Dropdown--split';
attrs.menuClassName += ' Dropdown-menu--right';
}
getButton(children) {
// Make a copy of the attrs of the first child component. We will assign
// these attrs to a new button, so that it has exactly the same behaviour as
// the first child.
const firstChild = this.getFirstChild(children);
const buttonAttrs = Object.assign({}, firstChild.attrs);
buttonAttrs.className = (buttonAttrs.className || '') + ' SplitDropdown-button Button ' + this.attrs.buttonClassName;
return [
Button.component(buttonAttrs, firstChild.children),
<button
className={'Dropdown-toggle Button Button--icon ' + this.attrs.buttonClassName}
aria-haspopup="menu"
aria-label={this.attrs.accessibleToggleLabel}
data-toggle="dropdown"
>
{icon(this.attrs.icon, { className: 'Button-icon' })}
{icon('fas fa-caret-down', { className: 'Button-caret' })}
</button>,
];
}
/**
* Get the first child. If the first child is an array, the first item in that
* array will be returned.
*
* @param {unknown[] | unknown} children
* @return {unknown}
* @protected
*/
getFirstChild(children) {
let firstChild = children;
while (firstChild instanceof Array) firstChild = firstChild[0];
return firstChild;
}
}

View File

@ -0,0 +1,56 @@
import Dropdown, { IDropdownAttrs } from './Dropdown';
import Button from './Button';
import icon from '../helpers/icon';
import Mithril from 'mithril';
import classList from '../utils/classList';
export interface ISplitDropdownAttrs extends IDropdownAttrs {}
/**
* The `SplitDropdown` component is similar to `Dropdown`, but the first child
* is displayed as its own button prior to the toggle button.
*/
export default class SplitDropdown extends Dropdown {
static initAttrs(attrs: ISplitDropdownAttrs) {
super.initAttrs(attrs);
attrs.className = classList(attrs.className, 'Dropdown--split');
attrs.menuClassName = classList(attrs.menuClassName, 'Dropdown-menu--right');
}
getButton(children: Mithril.ChildArray): Mithril.Vnode<any, any> {
// Make a copy of the attrs of the first child component. We will assign
// these attrs to a new button, so that it has exactly the same behaviour as
// the first child.
const firstChild = this.getFirstChild(children);
const buttonAttrs = Object.assign({}, firstChild?.attrs);
buttonAttrs.className = classList(buttonAttrs.className, 'SplitDropdown-button Button', this.attrs.buttonClassName);
return (
<>
{Button.component(buttonAttrs, firstChild.children)}
<button
className={'Dropdown-toggle Button Button--icon ' + this.attrs.buttonClassName}
aria-haspopup="menu"
aria-label={this.attrs.accessibleToggleLabel}
data-toggle="dropdown"
>
{this.attrs.icon ? icon(this.attrs.icon, { className: 'Button-icon' }) : null}
{icon('fas fa-caret-down', { className: 'Button-caret' })}
</button>
</>
);
}
/**
* Get the first child. If the first child is an array, the first item in that
* array will be returned.
*/
protected getFirstChild(children: Mithril.Children): Mithril.Vnode<any, any> {
let firstChild = children;
while (firstChild instanceof Array) firstChild = firstChild[0];
return firstChild as Mithril.Vnode<any, any>;
}
}

View File

@ -126,7 +126,7 @@ export default class ForumApplication extends Application {
// Route the home link back home when clicked. We do not want it to register
// if the user is opening it in a new tab, however.
document.getElementById('home-link')!.addEventListener('click', (e) => {
if (e.ctrlKey || e.metaKey || e.which === 2) return;
if (e.ctrlKey || e.metaKey || e.button === 1) return;
e.preventDefault();
app.history.home();

View File

@ -1,26 +1,31 @@
import app from '../../forum/app';
import Dropdown from '../../common/components/Dropdown';
import Dropdown, { IDropdownAttrs } from '../../common/components/Dropdown';
import icon from '../../common/helpers/icon';
import classList from '../../common/utils/classList';
import NotificationList from './NotificationList';
import extractText from '../../common/utils/extractText';
import type Mithril from 'mithril';
export default class NotificationsDropdown extends Dropdown {
static initAttrs(attrs) {
attrs.className = attrs.className || 'NotificationsDropdown';
attrs.buttonClassName = attrs.buttonClassName || 'Button Button--flat';
attrs.menuClassName = attrs.menuClassName || 'Dropdown-menu--right';
attrs.label = attrs.label || app.translator.trans('core.forum.notifications.tooltip');
attrs.icon = attrs.icon || 'fas fa-bell';
export interface INotificationsDropdown extends IDropdownAttrs {}
export default class NotificationsDropdown<CustomAttrs extends IDropdownAttrs = IDropdownAttrs> extends Dropdown<CustomAttrs> {
static initAttrs(attrs: INotificationsDropdown) {
attrs.className ||= 'NotificationsDropdown';
attrs.buttonClassName ||= 'Button Button--flat';
attrs.menuClassName ||= 'Dropdown-menu--right';
attrs.label ||= extractText(app.translator.trans('core.forum.notifications.tooltip'));
attrs.icon ||= 'fas fa-bell';
// For best a11y support, both `title` and `aria-label` should be used
attrs.accessibleToggleLabel = attrs.accessibleToggleLabel || app.translator.trans('core.forum.notifications.toggle_dropdown_accessible_label');
attrs.accessibleToggleLabel ||= extractText(app.translator.trans('core.forum.notifications.toggle_dropdown_accessible_label'));
super.initAttrs(attrs);
}
getButton() {
getButton(children: Mithril.ChildArray): Mithril.Vnode<any, any> {
const newNotifications = this.getNewCount();
const vdom = super.getButton();
const vdom = super.getButton(children);
vdom.attrs.title = this.attrs.label;
@ -30,11 +35,11 @@ export default class NotificationsDropdown extends Dropdown {
return vdom;
}
getButtonContent() {
getButtonContent(): Mithril.ChildArray {
const unread = this.getUnreadCount();
return [
icon(this.attrs.icon, { className: 'Button-icon' }),
this.attrs.icon ? icon(this.attrs.icon, { className: 'Button-icon' }) : null,
unread !== 0 && <span className="NotificationsDropdown-unread">{unread}</span>,
<span className="Button-label">{this.attrs.label}</span>,
];
@ -61,16 +66,16 @@ export default class NotificationsDropdown extends Dropdown {
}
getUnreadCount() {
return app.session.user.unreadNotificationCount();
return app.session.user!.unreadNotificationCount();
}
getNewCount() {
return app.session.user.newNotificationCount();
return app.session.user!.newNotificationCount();
}
menuClick(e) {
menuClick(e: MouseEvent) {
// Don't close the notifications dropdown if the user is opening a link in a
// new tab or window.
if (e.shiftKey || e.metaKey || e.ctrlKey || e.which === 2) e.stopPropagation();
if (e.shiftKey || e.metaKey || e.ctrlKey || e.button === 1) e.stopPropagation();
}
}

View File

@ -1,28 +1,32 @@
import app from '../../forum/app';
import avatar from '../../common/helpers/avatar';
import username from '../../common/helpers/username';
import Dropdown from '../../common/components/Dropdown';
import Dropdown, { IDropdownAttrs } from '../../common/components/Dropdown';
import LinkButton from '../../common/components/LinkButton';
import Button from '../../common/components/Button';
import ItemList from '../../common/utils/ItemList';
import Separator from '../../common/components/Separator';
import extractText from '../../common/utils/extractText';
import type Mithril from 'mithril';
export interface ISessionDropdownAttrs extends IDropdownAttrs {}
/**
* The `SessionDropdown` component shows a button with the current user's
* avatar/name, with a dropdown of session controls.
*/
export default class SessionDropdown extends Dropdown {
static initAttrs(attrs) {
export default class SessionDropdown<CustomAttrs extends ISessionDropdownAttrs = ISessionDropdownAttrs> extends Dropdown<CustomAttrs> {
static initAttrs(attrs: ISessionDropdownAttrs) {
super.initAttrs(attrs);
attrs.className = 'SessionDropdown';
attrs.buttonClassName = 'Button Button--user Button--flat';
attrs.menuClassName = 'Dropdown-menu--right';
attrs.accessibleToggleLabel = app.translator.trans('core.forum.header.session_dropdown_accessible_label');
attrs.accessibleToggleLabel = extractText(app.translator.trans('core.forum.header.session_dropdown_accessible_label'));
}
view(vnode) {
view(vnode: Mithril.Vnode<CustomAttrs, this>) {
return super.view({ ...vnode, children: this.items().toArray() });
}
@ -34,12 +38,10 @@ export default class SessionDropdown extends Dropdown {
/**
* Build an item list for the contents of the dropdown menu.
*
* @return {ItemList<import('mithril').Children>}
*/
items() {
const items = new ItemList();
const user = app.session.user;
items(): ItemList<Mithril.Children> {
const items = new ItemList<Mithril.Children>();
const user = app.session.user!;
items.add(
'profile',