diff --git a/framework/core/js/src/admin/AdminApplication.ts b/framework/core/js/src/admin/AdminApplication.ts index 69079b5df..3634b2b58 100644 --- a/framework/core/js/src/admin/AdminApplication.ts +++ b/framework/core/js/src/admin/AdminApplication.ts @@ -40,6 +40,7 @@ export interface AdminApplicationData extends ApplicationData { modelStatistics: Record; displayNameDrivers: string[]; slugDrivers: Record; + permissions: Record; } export default class AdminApplication extends Application { diff --git a/framework/core/js/src/admin/components/PermissionDropdown.js b/framework/core/js/src/admin/components/PermissionDropdown.tsx similarity index 65% rename from framework/core/js/src/admin/components/PermissionDropdown.js rename to framework/core/js/src/admin/components/PermissionDropdown.tsx index aa635d925..42c0fd718 100644 --- a/framework/core/js/src/admin/components/PermissionDropdown.js +++ b/framework/core/js/src/admin/components/PermissionDropdown.tsx @@ -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 extends Dropdown { + static initAttrs(attrs: IPermissionDropdownAttrs) { super.initAttrs(attrs); attrs.className = 'PermissionDropdown'; attrs.buttonClassName = 'Button Button--text'; } - view(vnode) { + view(vnode: Mithril.Vnode) { 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('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('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); } } diff --git a/framework/core/js/src/admin/components/SessionDropdown.js b/framework/core/js/src/admin/components/SessionDropdown.tsx similarity index 69% rename from framework/core/js/src/admin/components/SessionDropdown.js rename to framework/core/js/src/admin/components/SessionDropdown.tsx index 9e28775b4..e7606793a 100644 --- a/framework/core/js/src/admin/components/SessionDropdown.js +++ b/framework/core/js/src/admin/components/SessionDropdown.tsx @@ -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 extends Dropdown { + 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) { 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 { + const items = new ItemList(); items.add( 'logOut', diff --git a/framework/core/js/src/admin/components/SettingDropdown.js b/framework/core/js/src/admin/components/SettingDropdown.tsx similarity index 50% rename from framework/core/js/src/admin/components/SettingDropdown.js rename to framework/core/js/src/admin/components/SettingDropdown.tsx index 91953d67e..975fd2246 100644 --- a/framework/core/js/src/admin/components/SettingDropdown.js +++ b/framework/core/js/src/admin/components/SettingDropdown.tsx @@ -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; +} + +export default class SettingDropdown extends SelectDropdown { + 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) { 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 diff --git a/framework/core/js/src/common/components/Dropdown.js b/framework/core/js/src/common/components/Dropdown.js deleted file mode 100644 index 47c63b3a3..000000000 --- a/framework/core/js/src/common/components/Dropdown.js +++ /dev/null @@ -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 ( -
- {this.getButton(vnode.children)} - {renderItems && this.getMenu(items)} -
- ); - } - - 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 ( - - ); - } - - /** - * 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' }) : '', - {this.attrs.label}, - this.attrs.caretIcon ? icon(this.attrs.caretIcon, { className: 'Button-caret' }) : '', - ]; - } - - getMenu(items) { - return
    {items}
; - } -} diff --git a/framework/core/js/src/common/components/Dropdown.tsx b/framework/core/js/src/common/components/Dropdown.tsx new file mode 100644 index 000000000..9592fb256 --- /dev/null +++ b/framework/core/js/src/common/components/Dropdown.tsx @@ -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 extends Component { + 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) { + const items = vnode.children ? listItems(vnode.children as ModdedChildrenWithItemName[]) : []; + const renderItems = this.attrs.lazyDraw ? this.showing : true; + + return ( +
+ {this.getButton(vnode.children as Mithril.ChildArray)} + {renderItems && this.getMenu(items)} +
+ ); + } + + oncreate(vnode: Mithril.VnodeDOM) { + 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 { + return ( + + ); + } + + /** + * 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' }) : '', + {this.attrs.label}, + this.attrs.caretIcon ? icon(this.attrs.caretIcon, { className: 'Button-caret' }) : '', + ]; + } + + getMenu(items: Mithril.Vnode[]): Mithril.Vnode { + return
    {items}
; + } +} diff --git a/framework/core/js/src/common/components/Navigation.tsx b/framework/core/js/src/common/components/Navigation.tsx index de2cb3e2a..f4e687950 100644 --- a/framework/core/js/src/common/components/Navigation.tsx +++ b/framework/core/js/src/common/components/Navigation.tsx @@ -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(); }, diff --git a/framework/core/js/src/common/components/SelectDropdown.js b/framework/core/js/src/common/components/SelectDropdown.js deleted file mode 100644 index e7111ffe7..000000000 --- a/framework/core/js/src/common/components/SelectDropdown.js +++ /dev/null @@ -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 [{label}, icon(this.attrs.caretIcon, { className: 'Button-caret' })]; - } -} diff --git a/framework/core/js/src/common/components/SelectDropdown.tsx b/framework/core/js/src/common/components/SelectDropdown.tsx new file mode 100644 index 000000000..66c66fef5 --- /dev/null +++ b/framework/core/js/src/common/components/SelectDropdown.tsx @@ -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 extends Dropdown { + 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 [{label}, this.attrs.caretIcon ? icon(this.attrs.caretIcon, { className: 'Button-caret' }) : null]; + } +} diff --git a/framework/core/js/src/common/components/SplitDropdown.js b/framework/core/js/src/common/components/SplitDropdown.js deleted file mode 100644 index fea79e6af..000000000 --- a/framework/core/js/src/common/components/SplitDropdown.js +++ /dev/null @@ -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), - , - ]; - } - - /** - * 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; - } -} diff --git a/framework/core/js/src/common/components/SplitDropdown.tsx b/framework/core/js/src/common/components/SplitDropdown.tsx new file mode 100644 index 000000000..c9e5e3122 --- /dev/null +++ b/framework/core/js/src/common/components/SplitDropdown.tsx @@ -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 { + // 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)} + + + ); + } + + /** + * 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 { + let firstChild = children; + + while (firstChild instanceof Array) firstChild = firstChild[0]; + + return firstChild as Mithril.Vnode; + } +} diff --git a/framework/core/js/src/forum/ForumApplication.ts b/framework/core/js/src/forum/ForumApplication.ts index e5329b33b..f53baa252 100644 --- a/framework/core/js/src/forum/ForumApplication.ts +++ b/framework/core/js/src/forum/ForumApplication.ts @@ -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(); diff --git a/framework/core/js/src/forum/components/NotificationsDropdown.js b/framework/core/js/src/forum/components/NotificationsDropdown.tsx similarity index 51% rename from framework/core/js/src/forum/components/NotificationsDropdown.js rename to framework/core/js/src/forum/components/NotificationsDropdown.tsx index 6ec3bc3f0..c295f76bd 100644 --- a/framework/core/js/src/forum/components/NotificationsDropdown.js +++ b/framework/core/js/src/forum/components/NotificationsDropdown.tsx @@ -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 extends Dropdown { + 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 { 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 && {unread}, {this.attrs.label}, ]; @@ -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(); } } diff --git a/framework/core/js/src/forum/components/SessionDropdown.js b/framework/core/js/src/forum/components/SessionDropdown.tsx similarity index 75% rename from framework/core/js/src/forum/components/SessionDropdown.js rename to framework/core/js/src/forum/components/SessionDropdown.tsx index 456069cd2..5657b1439 100644 --- a/framework/core/js/src/forum/components/SessionDropdown.js +++ b/framework/core/js/src/forum/components/SessionDropdown.tsx @@ -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 extends Dropdown { + 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) { 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} */ - items() { - const items = new ItemList(); - const user = app.session.user; + items(): ItemList { + const items = new ItemList(); + const user = app.session.user!; items.add( 'profile',