diff --git a/extensions/embed/js/src/forum/index.js b/extensions/embed/js/src/forum/index.js index f858688120..75a6290897 100644 --- a/extensions/embed/js/src/forum/index.js +++ b/extensions/embed/js/src/forum/index.js @@ -36,7 +36,7 @@ app.pageInfo = Stream({}); const reposition = function () { const info = app.pageInfo(); - this.$().css('top', Math.max(0, info.scrollTop - info.offsetTop)); + this.element.style.top = Math.max(0, info.scrollTop - info.offsetTop) + 'px'; }; extend(ModalManager.prototype, 'show', reposition); @@ -50,7 +50,7 @@ window.iFrameResizer = { extend('flarum/forum/components/PostStream', 'goToNumber', function (promise, number) { if (number === 'reply' && 'parentIFrame' in window && app.composer.isFullScreen()) { - const itemTop = this.$('.PostStream-item:last').offset().top; + const itemTop = this.element.getBoundingClientRect().top + document.documentElement.scrollTop; window.parentIFrame.scrollToOffset(0, itemTop); } }); diff --git a/extensions/emoji/js/src/forum/addComposerAutocomplete.js b/extensions/emoji/js/src/forum/addComposerAutocomplete.js index 7d6df93903..120341bd74 100644 --- a/extensions/emoji/js/src/forum/addComposerAutocomplete.js +++ b/extensions/emoji/js/src/forum/addComposerAutocomplete.js @@ -24,7 +24,8 @@ export default function addComposerAutocomplete() { extend('flarum/common/components/TextEditor', 'onbuild', function () { this.emojiDropdown = new AutocompleteDropdown(); - const $editor = this.$('.TextEditor-editor').wrap('
'); + const editor = this.element.querySelector('.TextEditor-editor'); + editor.outerHTML = `
${editor.outerHTML}
`; this.navigator = new KeyboardNavigatable(); this.navigator @@ -33,9 +34,9 @@ export default function addComposerAutocomplete() { .onDown(() => this.emojiDropdown.navigate(1)) .onSelect(this.emojiDropdown.complete.bind(this.emojiDropdown)) .onCancel(this.emojiDropdown.hide.bind(this.emojiDropdown)) - .bindTo($editor); + .bindTo(editor); - $editor.after($('
')); + editor.outerHTML = editor.outerHTML + '
'; }); extend('flarum/common/components/TextEditor', 'buildEditorParams', function (params) { @@ -134,27 +135,28 @@ export default function addComposerAutocomplete() { if (suggestions.length) { this.emojiDropdown.items = suggestions; - m.render(this.$('.ComposerBody-emojiDropdownContainer')[0], this.emojiDropdown.render()); + m.render(this.element.querySelector('.ComposerBody-emojiDropdownContainer'), this.emojiDropdown.render()); this.emojiDropdown.show(); const coordinates = this.attrs.composer.editor.getCaretCoordinates(autocompleting.absoluteStart); - const width = this.emojiDropdown.$().outerWidth(); - const height = this.emojiDropdown.$().outerHeight(); - const parent = this.emojiDropdown.$().offsetParent(); + const rect = this.emojiDropdown.element.getBoundingClientRect(); + const width = rect.width; + const height = rect.height; + const parent = this.emojiDropdown.element.offsetParent; let left = coordinates.left; let top = coordinates.top + 15; // Keep the dropdown inside the editor. - if (top + height > parent.height()) { + if (top + height > parent.clientHeight) { top = coordinates.top - height - 15; } - if (left + width > parent.width()) { - left = parent.width() - width; + if (left + width > parent.clientWidth) { + left = parent.clientWidth - width; } // Prevent the dropdown from going off screen on mobile - top = Math.max(-(parent.offset().top - $(document).scrollTop()), top); - left = Math.max(-parent.offset().left, left); + top = Math.max(-(parent.getBoundingClientRect().top), top); + left = Math.max(-parent.getBoundingClientRect().left + document.documentElement.scrollLeft, left); this.emojiDropdown.show(left, top); } @@ -163,7 +165,7 @@ export default function addComposerAutocomplete() { buildSuggestions(); this.emojiDropdown.setIndex(0); - this.emojiDropdown.$().scrollTop(0); + this.emojiDropdown.element.scrollTo({ top: 0 }); this.emojiDropdown.active = true; } }); diff --git a/extensions/emoji/js/src/forum/fragments/AutocompleteDropdown.js b/extensions/emoji/js/src/forum/fragments/AutocompleteDropdown.js index e4f27921e3..086b56821d 100644 --- a/extensions/emoji/js/src/forum/fragments/AutocompleteDropdown.js +++ b/extensions/emoji/js/src/forum/fragments/AutocompleteDropdown.js @@ -18,17 +18,15 @@ export default class AutocompleteDropdown extends Fragment { } show(left, top) { - this.$() - .show() - .css({ - left: left + 'px', - top: top + 'px', - }); + const style = this.element.style; + style.display = 'block'; + style.left = left + 'px'; + style.top = top + 'px'; this.active = true; } hide() { - this.$().hide(); + this.element.style.display = 'none'; this.active = false; } @@ -40,42 +38,51 @@ export default class AutocompleteDropdown extends Fragment { } complete() { - this.$('li:not(.Dropdown-header)').eq(this.index).find('button').click(); + this.element.querySelectorAll('li:not(.Dropdown-header)')[this.index].querySelector('button').click(); } + // todo: check if copied implementation matches the original behavior setIndex(index, scrollToItem) { if (this.keyWasJustPressed && !scrollToItem) return; - const $dropdown = this.$(); - const $items = $dropdown.find('li:not(.Dropdown-header)'); + const dropdown = this.element; + const items = dropdown.querySelectorAll('li:not(.Dropdown-header)'); let rangedIndex = index; if (rangedIndex < 0) { - rangedIndex = $items.length - 1; - } else if (rangedIndex >= $items.length) { + rangedIndex = items.length - 1; + } else if (rangedIndex >= items.length) { rangedIndex = 0; } this.index = rangedIndex; - const $item = $items.removeClass('active').eq(rangedIndex).addClass('active'); + items.forEach((el) => el.classList.remove('active')); + const item = items[rangedIndex]; + item.classList.add('active'); if (scrollToItem) { - const dropdownScroll = $dropdown.scrollTop(); - const dropdownTop = $dropdown.offset().top; - const dropdownBottom = dropdownTop + $dropdown.outerHeight(); - const itemTop = $item.offset().top; - const itemBottom = itemTop + $item.outerHeight(); + const documentScrollTop = document.documentElement.scrollTop; + const dropdownScroll = dropdown.scrollTop; + const dropdownRect = dropdown.getBoundingClientRect(); + const dropdownTop = dropdownRect.top + documentScrollTop; + const dropdownBottom = dropdownTop + dropdownRect.height; + const itemRect = item.getBoundingClientRect(); + const itemTop = itemRect.top + documentScrollTop; + const itemBottom = itemTop + itemRect.height; let scrollTop; if (itemTop < dropdownTop) { - scrollTop = dropdownScroll - dropdownTop + itemTop - parseInt($dropdown.css('padding-top'), 10); + scrollTop = dropdownScroll - dropdownTop + itemTop - parseInt(getComputedStyle(dropdown).paddingTop, 10); } else if (itemBottom > dropdownBottom) { - scrollTop = dropdownScroll - dropdownBottom + itemBottom + parseInt($dropdown.css('padding-bottom'), 10); + scrollTop = dropdownScroll - dropdownBottom + itemBottom + parseInt(getComputedStyle(dropdown).paddingBottom, 10); } if (typeof scrollTop !== 'undefined') { - $dropdown.stop(true).animate({ scrollTop }, 100); + dropdown.scrollTo({ + top: scrollTop, + behavior: 'smooth', + }); } } } diff --git a/framework/core/js/package.json b/framework/core/js/package.json index 249a53a55a..91146c9bfd 100644 --- a/framework/core/js/package.json +++ b/framework/core/js/package.json @@ -5,15 +5,15 @@ "type": "module", "prettier": "@flarum/prettier-config", "dependencies": { + "@popperjs/core": "^2.11.8", "body-scroll-lock": "^4.0.0-beta.0", - "bootstrap": "^3.4.1", + "bootstrap": "^5.3.3", "clsx": "^1.1.1", "color-thief-browser": "^2.0.2", "dayjs": "^1.10.7", "focus-trap": "^6.7.1", "format-message": "^6.2.4", "jquery": "^3.6.0", - "jquery.hotkeys": "^0.1.0", "mithril": "^2.2", "nanoid": "^3.1.30", "punycode": "^2.1.1", @@ -24,7 +24,7 @@ "@flarum/jest-config": "^1.0.0", "@flarum/prettier-config": "^1.0.0", "@types/body-scroll-lock": "^3.1.0", - "@types/jquery": "^3.5.10", + "@types/bootstrap": "^5.2.10", "@types/mithril": "^2.0.8", "@types/punycode": "^2.1.0", "@types/textarea-caret": "^3.0.1", diff --git a/framework/core/js/src/@types/global.d.ts b/framework/core/js/src/@types/global.d.ts index 0e10ddecb2..b722e52cb6 100644 --- a/framework/core/js/src/@types/global.d.ts +++ b/framework/core/js/src/@types/global.d.ts @@ -56,6 +56,14 @@ declare type VnodeElementTag, C extends Componen */ declare const app: import('../common/Application').default; +/** + * @deprecated We are moving away from jQuery. + */ +declare const $: JQueryStatic; +/** + * @deprecated We are moving away from jQuery. + */ +declare const jQuery: JQueryStatic; declare const m: import('mithril').Static; declare const dayjs: typeof import('dayjs'); diff --git a/framework/core/js/src/admin/components/AdminNav.js b/framework/core/js/src/admin/components/AdminNav.js index 98e4fd8784..5f3c7e824e 100644 --- a/framework/core/js/src/admin/components/AdminNav.js +++ b/framework/core/js/src/admin/components/AdminNav.js @@ -37,20 +37,17 @@ export default class AdminNav extends Component { } scrollToActive() { - const children = $('.Dropdown-menu').children('.active'); - const nav = $('#admin-navigation'); - const time = app.previous.type ? 250 : 0; + const children = document.querySelectorAll('.Dropdown-menu > .active'); + const nav = document.getElementById('admin-navigation'); if ( children.length > 0 && - (children[0].offsetTop > nav.scrollTop() + nav.outerHeight() || children[0].offsetTop + children[0].offsetHeight < nav.scrollTop()) + (children[0].offsetTop > nav.scrollTop + nav.getBoundingClientRect().height || children[0].offsetTop + children[0].offsetHeight < nav.scrollTop) ) { - nav.animate( - { - scrollTop: children[0].offsetTop - nav.height() / 2, - }, - time - ); + nav.scrollTo({ + top: children[0].offsetTop - nav.clientHeight / 2, + behavior: app.previous.type ? 'smooth' : 'instant' + }); } } diff --git a/framework/core/js/src/admin/components/CreateUserModal.tsx b/framework/core/js/src/admin/components/CreateUserModal.tsx index 3d358f21fc..6079edb776 100644 --- a/framework/core/js/src/admin/components/CreateUserModal.tsx +++ b/framework/core/js/src/admin/components/CreateUserModal.tsx @@ -199,7 +199,7 @@ export default class CreateUserModal { function setEmailVisibility(visible: boolean) { - // Get needed jQuery element refs - const emailContainer = $(`[data-column-name=emailAddress][data-user-id=${user.id()}] .UserList-email`); - const emailAddress = emailContainer.find('.UserList-emailAddress'); - const emailToggleButton = emailContainer.find('.UserList-emailIconBtn'); - const emailToggleButtonIcon = emailToggleButton.find('.icon'); + // Get needed element refs + const emailContainer = document.querySelector(`[data-column-name='emailAddress'][data-user-id='${user.id()}'] .UserList-email`)!; + const emailAddress = emailContainer.querySelector('.UserList-emailAddress')!; + const emailToggleButton = emailContainer.querySelector('.UserList-emailIconBtn')!; + const emailToggleButtonIcon = emailToggleButton.querySelector('.icon')!; - emailToggleButton.attr( + emailToggleButton.setAttribute( 'title', extractText( visible @@ -318,23 +318,27 @@ export default class UserListPage extends AdminPage { ) ); - emailAddress.attr('aria-hidden', visible ? null : 'true'); + if (visible) { + emailAddress.removeAttribute('aria-hidden'); + } else { + emailAddress.setAttribute('aria-hidden', 'true'); + } if (visible) { - emailToggleButtonIcon.addClass('fa-eye'); - emailToggleButtonIcon.removeClass('fa-eye-slash'); + emailToggleButtonIcon.classList.add('fa-eye'); + emailToggleButtonIcon.classList.remove('fa-eye-slash'); } else { - emailToggleButtonIcon.removeClass('fa-eye'); - emailToggleButtonIcon.addClass('fa-eye-slash'); + emailToggleButtonIcon.classList.remove('fa-eye'); + emailToggleButtonIcon.classList.add('fa-eye-slash'); } // Need the string interpolation to prevent TS error. - emailContainer.attr('data-email-shown', `${visible}`); + emailContainer.setAttribute('data-email-shown', `${visible}`); } function toggleEmailVisibility() { - const emailContainer = $(`[data-column-name=emailAddress][data-user-id=${user.id()}] .UserList-email`); - const emailShown = emailContainer.attr('data-email-shown') === 'true'; + const emailContainer = document.querySelector(`[data-column-name='emailAddress'][data-user-id='${user.id()}'] .UserList-email`)!; + const emailShown = emailContainer.getAttribute('data-email-shown') === 'true'; if (emailShown) { setEmailVisibility(false); diff --git a/framework/core/js/src/common/Component.ts b/framework/core/js/src/common/Component.ts index fdf244443e..90e3607912 100644 --- a/framework/core/js/src/common/Component.ts +++ b/framework/core/js/src/common/Component.ts @@ -108,6 +108,7 @@ export default abstract class Component; diff --git a/framework/core/js/src/common/Fragment.ts b/framework/core/js/src/common/Fragment.ts index 4d0f5416cb..c78835ddb0 100644 --- a/framework/core/js/src/common/Fragment.ts +++ b/framework/core/js/src/common/Fragment.ts @@ -33,6 +33,7 @@ export default abstract class Fragment { * @param [selector] a jQuery-compatible selector string * @returns the jQuery object for the DOM node * @final + * @deprecated We are moving away from jQuery. */ public $(selector?: string): JQuery { const $element = $(this.element) as JQuery; diff --git a/framework/core/js/src/common/components/AbstractGlobalSearch.tsx b/framework/core/js/src/common/components/AbstractGlobalSearch.tsx index ccc8614f8a..8e3e702acb 100644 --- a/framework/core/js/src/common/components/AbstractGlobalSearch.tsx +++ b/framework/core/js/src/common/components/AbstractGlobalSearch.tsx @@ -86,12 +86,17 @@ export default abstract class AbstractGlobalSearch; const openSearchModal = () => { - this.$('input').blur() && + this.blur() && app.modal.show(() => import('../../common/components/SearchModal'), { searchState: this.searchState, sources: this.sourceItems().toArray() }); }; @@ -101,7 +106,7 @@ export default abstract class AbstractGlobalSearch { - this.$('input').blur(); + this.blur(); setTimeout(() => openSearchModal(), 150); }} > @@ -124,7 +129,7 @@ export default abstract class AbstractGlobalSearch { if (e.key === 'Enter') { e.preventDefault(); - this.$('input').blur() && openSearchModal(); + this.blur() && openSearchModal(); } }, }} diff --git a/framework/core/js/src/common/components/AutocompleteDropdown.tsx b/framework/core/js/src/common/components/AutocompleteDropdown.tsx index 88f01c90f5..beb1aeca27 100644 --- a/framework/core/js/src/common/components/AutocompleteDropdown.tsx +++ b/framework/core/js/src/common/components/AutocompleteDropdown.tsx @@ -84,35 +84,35 @@ export default abstract class AutocompleteDropdown< // Highlight the item that is currently selected. this.setIndex(this.getCurrentNumericIndex()); - this.$('.Dropdown-suggestions') - .on('mousedown', (e) => e.preventDefault()) - // Whenever the mouse is hovered over a search result, highlight it. - .on('mouseenter', '> li:not(.Dropdown-header)', function () { - component.setIndex(component.selectableItems().index(this)); - }); + const suggestions = this.element.querySelector('.Dropdown-suggestions')! as HTMLDivElement; + suggestions.addEventListener('mousedown', (e) => e.preventDefault()); + // Whenever the mouse is hovered over a search result, highlight it. + suggestions.addEventListener('mouseenter', (e) => { + const el = e.target as HTMLElement; + if (el.parentElement != suggestions || el.tagName != 'LI' || el.classList.contains('Dropdown-header')) return; + component.setIndex(component.selectableItems().indexOf(el as HTMLLIElement)); + }); - const $input = this.inputElement(); + const input = this.inputElement(); this.navigator = new KeyboardNavigatable(); this.navigator .onUp(() => this.setIndex(this.getCurrentNumericIndex() - 1, true)) .onDown(() => this.setIndex(this.getCurrentNumericIndex() + 1, true)) .onSelect(this.selectSuggestion.bind(this), true) - .bindTo($input); - - $input - .on('focus', function () { - component.hasFocus = true; - m.redraw(); - - $(this) - .one('mouseup', (e) => e.preventDefault()) - .trigger('select'); - }) - .on('blur', function () { - component.hasFocus = false; - m.redraw(); - }); + .bindTo(input); + + input.addEventListener('focus', function () { + component.hasFocus = true; + m.redraw(); + + this.addEventListener('mouseup', (e) => e.preventDefault(), { once: true }); + this.select(); + }); + input.addEventListener('blur', () => { + component.hasFocus = false; + m.redraw(); + }); this.updateMaxHeightHandler = this.updateMaxHeight.bind(this); window.addEventListener('resize', this.updateMaxHeightHandler); @@ -126,16 +126,16 @@ export default abstract class AutocompleteDropdown< } } - selectableItems(): JQuery { - return this.$('.Dropdown-suggestions > li:not(.Dropdown-header)'); + selectableItems(): HTMLLIElement[] { + return Array.from(this.element.querySelectorAll('.Dropdown-suggestions > li:not(.Dropdown-header)')) as HTMLLIElement[]; } - inputElement(): JQuery { - return this.$('input') as JQuery; + inputElement(): HTMLInputElement { + return this.element.querySelector('input') as HTMLInputElement; } selectSuggestion() { - this.getItem(this.index).find('button')[0].click(); + this.getItem(this.index).querySelector('button')!.click(); } /** @@ -143,21 +143,21 @@ export default abstract class AutocompleteDropdown< * Returns zero if not found. */ getCurrentNumericIndex(): number { - return Math.max(0, this.selectableItems().index(this.getItem(this.index))); + return Math.max(0, this.selectableItems().indexOf(this.getItem(this.index))); } /** * Get the
  • in the search results with the given index (numeric or named). */ - getItem(index: number): JQuery { - const $items = this.selectableItems(); - let $item = $items.filter(`[data-index="${index}"]`); + getItem(index: number): HTMLLIElement { + const items = this.selectableItems(); + const filtered = items.filter((v) => v.getAttribute('data-index') == index.toString()); - if (!$item.length) { - $item = $items.eq(index); + if (!filtered.length) { + return items[index]; } - return $item; + return filtered[0]; } /** @@ -165,36 +165,44 @@ export default abstract class AutocompleteDropdown< * index. */ setIndex(index: number, scrollToItem: boolean = false) { - const $items = this.selectableItems(); - const $dropdown = $items.parent(); + const items = this.selectableItems(); let fixedIndex = index; if (index < 0) { - fixedIndex = $items.length - 1; - } else if (index >= $items.length) { + fixedIndex = items.length - 1; + } else if (index >= items.length) { fixedIndex = 0; } - const $item = $items.removeClass('active').eq(fixedIndex).addClass('active'); + items.forEach((el) => el.classList.remove('active')); + const item = items[fixedIndex]; + const dropdown = item.parentElement!; + item.classList.add('active'); - this.index = parseInt($item.attr('data-index') as string) || fixedIndex; + this.index = parseInt(item.getAttribute('data-index') as string) || fixedIndex; if (scrollToItem) { - const dropdownScroll = $dropdown.scrollTop()!; - const dropdownTop = $dropdown.offset()!.top; - const dropdownBottom = dropdownTop + $dropdown.outerHeight()!; - const itemTop = $item.offset()!.top; - const itemBottom = itemTop + $item.outerHeight()!; + const documentScrollTop = document.documentElement.scrollTop; + const dropdownScroll = dropdown.scrollTop!; + const dropdownRect = dropdown.getBoundingClientRect(); + const dropdownTop = dropdownRect.top + documentScrollTop; + const dropdownBottom = dropdownTop + dropdownRect.height; + const itemRect = item.getBoundingClientRect(); + const itemTop = itemRect.top + documentScrollTop; + const itemBottom = itemTop + itemRect.height; let scrollTop; if (itemTop < dropdownTop) { - scrollTop = dropdownScroll - dropdownTop + itemTop - parseInt($dropdown.css('padding-top'), 10); + scrollTop = dropdownScroll - dropdownTop + itemTop - parseInt(getComputedStyle(dropdown).paddingTop, 10); } else if (itemBottom > dropdownBottom) { - scrollTop = dropdownScroll - dropdownBottom + itemBottom + parseInt($dropdown.css('padding-bottom'), 10); + scrollTop = dropdownScroll - dropdownBottom + itemBottom + parseInt(getComputedStyle(dropdown).paddingBottom, 10); } if (typeof scrollTop !== 'undefined') { - $dropdown.stop(true).animate({ scrollTop }, 100); + dropdown.scrollTo({ + top: scrollTop, + behavior: 'smooth', + }); } } } diff --git a/framework/core/js/src/common/components/ConfirmDocumentUnload.js b/framework/core/js/src/common/components/ConfirmDocumentUnload.js index 063fb79747..95cc1ea92c 100644 --- a/framework/core/js/src/common/components/ConfirmDocumentUnload.js +++ b/framework/core/js/src/common/components/ConfirmDocumentUnload.js @@ -19,13 +19,13 @@ export default class ConfirmDocumentUnload extends Component { super.oncreate(vnode); this.boundHandler = this.handler.bind(this); - $(window).on('beforeunload', this.boundHandler); + window.addEventListener('beforeunload', this.boundHandler); } onremove(vnode) { super.onremove(vnode); - $(window).off('beforeunload', this.boundHandler); + window.removeEventListener('beforeunload', this.boundHandler); } view(vnode) { diff --git a/framework/core/js/src/common/components/Dropdown.tsx b/framework/core/js/src/common/components/Dropdown.tsx index 268159cc4b..c014e74054 100644 --- a/framework/core/js/src/common/components/Dropdown.tsx +++ b/framework/core/js/src/common/components/Dropdown.tsx @@ -40,6 +40,8 @@ export interface IDropdownAttrs extends ComponentAttrs { export default class Dropdown extends Component { protected showing = false; + protected backdropElement: HTMLDivElement | null = null; + static initAttrs(attrs: IDropdownAttrs) { attrs.className ||= ''; attrs.buttonClassName ||= ''; @@ -67,7 +69,7 @@ export default class Dropdown { + this.element.addEventListener('shown.bs.dropdown', () => { const { lazyDraw, onshow } = this.attrs; this.showing = true; @@ -88,31 +90,16 @@ export default class Dropdown 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); + // Mithril doesn't really redraw this component sometimes (e.g. Discussion list) + // Bootstrap 5 has removed the open class toggle and the backdrop + // these need to be added manually. + this.element.classList.add('open'); + this.backdropElement = document.createElement('div'); + this.backdropElement.classList.add('dropdown-backdrop'); + this.element.append(this.backdropElement); }); - this.$().on('hidden.bs.dropdown', () => { + this.element.addEventListener('hidden.bs.dropdown', () => { this.showing = false; if (this.attrs.onhide) { @@ -120,6 +107,8 @@ export default class Dropdown diff --git a/framework/core/js/src/common/components/EditUserModal.tsx b/framework/core/js/src/common/components/EditUserModal.tsx index b5bed5e2a0..36707005ac 100644 --- a/framework/core/js/src/common/components/EditUserModal.tsx +++ b/framework/core/js/src/common/components/EditUserModal.tsx @@ -109,7 +109,7 @@ export default class EditUserModal { ); requestAnimationFrame(() => { - this.activeDialogElement?.classList.add('in'); + this.activeDialogElement?.classList.add('show'); }); } diff --git a/framework/core/js/src/common/components/Page.tsx b/framework/core/js/src/common/components/Page.tsx index b47183d439..e491d350f1 100644 --- a/framework/core/js/src/common/components/Page.tsx +++ b/framework/core/js/src/common/components/Page.tsx @@ -43,11 +43,11 @@ export default abstract class Page { this.query(value); - this.inputScroll(this.inputElement()[0]?.scrollLeft ?? 0); + this.inputScroll(this.inputElement()?.scrollLeft ?? 0); }} inputAttrs={{ className: 'SearchModal-input' }} renderInput={(attrs: any) => ( @@ -218,7 +218,7 @@ export default class SearchModal; + const input = this.inputElement(); + if (!input) return; this.navigator = new KeyboardNavigatable(); this.navigator @@ -288,12 +289,14 @@ export default class SearchModal this.setIndex(this.getCurrentNumericIndex() + 1, true)) .onSelect(this.selectResult.bind(this), true) .onCancel(this.clear.bind(this)) - .bindTo($input); + .bindTo(input); // Handle input key events on the search input, triggering results to load. - $input.on('input focus', function () { - search(this.value.toLowerCase()); - }); + ['input', 'focus'].forEach((ev) => + input.addEventListener(ev as 'input' | 'focus', function () { + search(this.value.toLowerCase()); + }) + ); } onremove(vnode: Mithril.VnodeDOM) { @@ -430,8 +433,8 @@ export default class SearchModal { - return this.$('.SearchModal-input') as JQuery; + inputElement(): HTMLInputElement | null { + return this.element?.querySelector('.SearchModal-input'); } defaultActiveSource(): string | null { diff --git a/framework/core/js/src/common/components/TextEditor.js b/framework/core/js/src/common/components/TextEditor.js index 2360df1338..0139fde908 100644 --- a/framework/core/js/src/common/components/TextEditor.js +++ b/framework/core/js/src/common/components/TextEditor.js @@ -77,7 +77,7 @@ export default class TextEditor extends Component { } onbuild() { - this.attrs.composer.editor = this.buildEditor(this.$('.TextEditor-editorContainer')[0]); + this.attrs.composer.editor = this.buildEditor(this.element.querySelector('.TextEditor-editorContainer')); } onupdate(vnode) { diff --git a/framework/core/js/src/common/components/Tooltip.tsx b/framework/core/js/src/common/components/Tooltip.tsx index 7c0791a057..49213b33e4 100644 --- a/framework/core/js/src/common/components/Tooltip.tsx +++ b/framework/core/js/src/common/components/Tooltip.tsx @@ -2,6 +2,7 @@ import Component from '../Component'; import type Mithril from 'mithril'; import classList from '../utils/classList'; import extractText from '../utils/extractText'; +import BootstrapTooltip from 'bootstrap/js/dist/tooltip'; export interface TooltipAttrs extends Mithril.CommonAttributes { /** @@ -97,6 +98,8 @@ export interface TooltipAttrs extends Mithril.CommonAttributes */ export default class Tooltip extends Component { + private tooltip: BootstrapTooltip | null = null; + private firstChild: Mithril.Vnode | null = null; private childDomNode: HTMLElement | null = null; @@ -186,11 +189,7 @@ export default class Tooltip extends Component { private recreateTooltip() { if (this.shouldRecreateTooltip && this.childDomNode !== null) { - $(this.childDomNode).tooltip( - 'destroy', - // @ts-expect-error We don't want this arg to be part of the public API. It only exists to prevent deprecation warnings when using `$.tooltip` in this component. - 'DANGEROUS_tooltip_jquery_fn_deprecation_exempt' - ); + this.tooltip?.dispose(); this.createTooltip(); this.shouldRecreateTooltip = false; } @@ -205,17 +204,9 @@ export default class Tooltip extends Component { if (this.childDomNode === null) return; if (this.attrs.tooltipVisible === true) { - $(this.childDomNode).tooltip( - 'show', - // @ts-expect-error We don't want this arg to be part of the public API. It only exists to prevent deprecation warnings when using `$.tooltip` in this component. - 'DANGEROUS_tooltip_jquery_fn_deprecation_exempt' - ); + this.tooltip?.show(); } else if (this.attrs.tooltipVisible === false) { - $(this.childDomNode).tooltip( - 'hide', - // @ts-expect-error We don't want this arg to be part of the public API. It only exists to prevent deprecation warnings when using `$.tooltip` in this component. - 'DANGEROUS_tooltip_jquery_fn_deprecation_exempt' - ); + this.tooltip?.hide(); } } @@ -239,17 +230,13 @@ export default class Tooltip extends Component { this.childDomNode.setAttribute('title', realText); this.childDomNode.setAttribute('aria-label', realText); - // https://getbootstrap.com/docs/3.3/javascript/#tooltips-options - $(this.childDomNode).tooltip( - { - html, - delay, - placement: position, - trigger, - }, - // @ts-expect-error We don't want this arg to be part of the public API. It only exists to prevent deprecation warnings when using `$.tooltip` in this component. - 'DANGEROUS_tooltip_jquery_fn_deprecation_exempt' - ); + // https://getbootstrap.com/docs/5.0/components/tooltips/#options + this.tooltip = new BootstrapTooltip(this.childDomNode, { + html, + delay: delay || 0, + placement: position, + trigger: trigger as any, + }); } private getRealText(): string { diff --git a/framework/core/js/src/common/components/UploadImageButton.tsx b/framework/core/js/src/common/components/UploadImageButton.tsx index 4a55db0769..26708b9946 100644 --- a/framework/core/js/src/common/components/UploadImageButton.tsx +++ b/framework/core/js/src/common/components/UploadImageButton.tsx @@ -52,29 +52,28 @@ export default class UploadImageButton'); - - $input - .appendTo('body') - .hide() - .trigger('click') - .on('change', (e) => { - const body = new FormData(); - // @ts-ignore - body.append(this.attrs.name, $(e.target)[0].files[0]); - - this.loading = true; - m.redraw(); - - app - .request({ - method: 'POST', - url: this.resourceUrl(), - serialize: (raw) => raw, - body, - }) - .then(this.success.bind(this), this.failure.bind(this)); - }); + const input = document.createElement('input'); + input.type = 'file'; + input.style.display = 'none'; + document.body.append(input); + input.click(); + input.addEventListener('change', (e) => { + const body = new FormData(); + // @ts-ignore + body.append(this.attrs.name, $(e.target)[0].files[0]); + + this.loading = true; + m.redraw(); + + app + .request({ + method: 'POST', + url: this.resourceUrl(), + serialize: (raw) => raw, + body, + }) + .then(this.success.bind(this), this.failure.bind(this)); + }); } remove() { diff --git a/framework/core/js/src/common/helpers/highlight.tsx b/framework/core/js/src/common/helpers/highlight.tsx index bebfbdf5f4..41d9a0b061 100644 --- a/framework/core/js/src/common/helpers/highlight.tsx +++ b/framework/core/js/src/common/helpers/highlight.tsx @@ -32,7 +32,9 @@ export default function highlight(string: string, phrase?: string | RegExp, leng // Convert the string into HTML entities, then highlight all matches with // tags. Then we will return the result as a trusted HTML string. if (!safe) { - highlighted = $('
    ').text(highlighted).html(); + const el = document.createElement('div'); + el.textContent = highlighted; + highlighted = el.innerHTML; } if (phrase) highlighted = highlighted.replace(regexp, '$&'); diff --git a/framework/core/js/src/common/index.ts b/framework/core/js/src/common/index.ts index 0b7deb7004..d5ff24a54d 100644 --- a/framework/core/js/src/common/index.ts +++ b/framework/core/js/src/common/index.ts @@ -3,15 +3,17 @@ import 'expose-loader?exposes=$,jQuery!jquery'; import 'expose-loader?exposes=m!mithril'; import 'expose-loader?exposes=dayjs!dayjs'; -import 'bootstrap/js/affix'; -import 'bootstrap/js/dropdown'; -import 'bootstrap/js/tooltip'; -import 'bootstrap/js/transition'; -import 'jquery.hotkeys/jquery.hotkeys'; - +import Dropdown from 'bootstrap/js/dist/dropdown'; import relativeTime from 'dayjs/plugin/relativeTime'; import localizedFormat from 'dayjs/plugin/localizedFormat'; +import popperMobileModifier from './utils/popperMobileModifier'; + +Dropdown.Default.popperConfig = { + strategy: 'fixed', + modifiers: [popperMobileModifier], +}; + dayjs.extend(relativeTime); dayjs.extend(localizedFormat); @@ -26,19 +28,3 @@ import app from './app'; export { app }; import './utils/arrayFlatPolyfill'; - -const tooltipGen = $.fn.tooltip; - -// Remove in a future version of Flarum. -// @ts-ignore -$.fn.tooltip = function (options, caller) { - // Show a warning when `$.tooltip` is used outside of the Tooltip component. - // This functionality is deprecated and should not be used. - if (!['DANGEROUS_tooltip_jquery_fn_deprecation_exempt'].includes(caller)) { - console.warn( - "Calling `$.tooltip` is now deprecated. Please use the `` component exposed by flarum/core instead. `$.tooltip` may be removed in a future version of Flarum.\n\nIf this component doesn't meet your requirements, please open an issue: https://github.com/flarum/core/issues/new?assignees=davwheat&labels=type/bug,needs-verification&template=bug-report.md&title=Tooltip%20component%20unsuitable%20for%20use%20case" - ); - } - - tooltipGen.bind(this)(options); -}; diff --git a/framework/core/js/src/common/utils/Drawer.js b/framework/core/js/src/common/utils/Drawer.js index dbf66945bc..f3d0a3f79c 100644 --- a/framework/core/js/src/common/utils/Drawer.js +++ b/framework/core/js/src/common/utils/Drawer.js @@ -87,14 +87,15 @@ export default class Drawer { if (!this.isOpen()) return; - const $drawer = $('#drawer'); + const drawer = document.getElementById('drawer'); // Used to prevent `visibility: hidden` from breaking the exit animation - $drawer.css('visibility', 'visible').one('transitionend', () => $drawer.css('visibility', '')); + drawer.style.visibility = 'visible'; + drawer.addEventListener('transitionend', () => (drawer.style.visibility = ''), { once: true }); this.appElement.classList.remove('drawerOpen'); - this.$backdrop?.remove?.(); + this.backdrop.remove(); } /** @@ -105,10 +106,13 @@ export default class Drawer { this.drawerAvailableMediaQuery.addListener(this.resizeHandler); - this.$backdrop = $('
    ').addClass('drawer-backdrop fade').appendTo('body').on('click', this.hide.bind(this)); + this.backdrop = document.createElement('div'); + this.backdrop.classList.add('drawer-backdrop', 'fade'); + this.backdrop.addEventListener('click', this.hide.bind(this)); + document.body.append(this.backdrop); requestAnimationFrame(() => { - this.$backdrop.addClass('in'); + this.backdrop.classList.add('show'); this.focusTrap.activate(); }); diff --git a/framework/core/js/src/common/utils/GambitsAutocomplete.tsx b/framework/core/js/src/common/utils/GambitsAutocomplete.tsx index 6379d612d2..78e99212ed 100644 --- a/framework/core/js/src/common/utils/GambitsAutocomplete.tsx +++ b/framework/core/js/src/common/utils/GambitsAutocomplete.tsx @@ -9,7 +9,7 @@ export default class GambitsAutocomplete { constructor( public resource: string, - public jqueryInput: () => JQuery, + public inputElement: () => HTMLInputElement | null, public onchange: (value: string) => void, public afterSuggest: (value: string) => void ) {} @@ -55,7 +55,7 @@ export default class GambitsAutocomplete { const autocompleteReader = new AutocompleteReader(null); - const cursorPosition = this.jqueryInput().prop('selectionStart') || query.length; + const cursorPosition = this.inputElement()?.selectionStart || query.length; const lastChunk = query.slice(0, cursorPosition); const autocomplete = autocompleteReader.check(lastChunk, cursorPosition, /\S+$/); @@ -157,15 +157,15 @@ export default class GambitsAutocomplete { } suggest(text: string, fromTyped: string, start: number) { - const $input = this.jqueryInput(); + const input = this.inputElement(); const query = this.query; const replaced = query.slice(0, start) + text + query.slice(start + fromTyped.length); this.onchange(replaced); - $input[0].focus(); + input?.focus(); setTimeout(() => { - $input[0].setSelectionRange(start + text.length, start + text.length); + input?.setSelectionRange(start + text.length, start + text.length); m.redraw(); }, 50); diff --git a/framework/core/js/src/common/utils/KeyboardNavigatable.ts b/framework/core/js/src/common/utils/KeyboardNavigatable.ts index 4966a836c2..611c29bacb 100644 --- a/framework/core/js/src/common/utils/KeyboardNavigatable.ts +++ b/framework/core/js/src/common/utils/KeyboardNavigatable.ts @@ -151,11 +151,11 @@ export default class KeyboardNavigatable { } /** - * Set up the navigation key bindings on the given jQuery element. + * Set up the navigation key bindings on the given element. */ - bindTo($element: JQuery) { + bindTo(element: HTMLElement) { // Handle navigation key events on the navigatable element. - $element[0].addEventListener('keydown', this.navigate.bind(this)); + element.addEventListener('keydown', this.navigate.bind(this)); } /** diff --git a/framework/core/js/src/common/utils/anchorScroll.js b/framework/core/js/src/common/utils/anchorScroll.js index 8a18ea48df..35056cd6d5 100644 --- a/framework/core/js/src/common/utils/anchorScroll.js +++ b/framework/core/js/src/common/utils/anchorScroll.js @@ -12,10 +12,11 @@ * @param {() => void} callback The callback to run that will change page content. */ export default function anchorScroll(element, callback) { - const $window = $(window); - const relativeScroll = $(element).offset().top - $window.scrollTop(); + if (typeof element === 'string') element = document.querySelector(element); + if (!element) return; + const relativeScroll = element.getBoundingClientRect().top; callback(); - $window.scrollTop($(element).offset().top - relativeScroll); + window.scrollTo({ top: element.getBoundingClientRect().top + document.documentElement.scrollTop - relativeScroll }); } diff --git a/framework/core/js/src/common/utils/heightWithMargin.ts b/framework/core/js/src/common/utils/heightWithMargin.ts new file mode 100644 index 0000000000..c983b37014 --- /dev/null +++ b/framework/core/js/src/common/utils/heightWithMargin.ts @@ -0,0 +1,4 @@ +export default function heightWithMargin(element: HTMLElement): number { + const style = getComputedStyle(element); + return element.getBoundingClientRect().height + parseInt(style.marginBottom, 10) + parseInt(style.marginTop, 10); +} diff --git a/framework/core/js/src/common/utils/liveHumanTimes.ts b/framework/core/js/src/common/utils/liveHumanTimes.ts index bcb0d53754..f054c39cd7 100644 --- a/framework/core/js/src/common/utils/liveHumanTimes.ts +++ b/framework/core/js/src/common/utils/liveHumanTimes.ts @@ -1,11 +1,10 @@ import humanTime from './humanTime'; function updateHumanTimes() { - $('[data-humantime]').each(function () { - const $this = $(this); - const ago = humanTime($this.attr('datetime')); + document.querySelectorAll('[data-humantime]').forEach(function (el) { + const ago = humanTime(el.getAttribute('datetime')); - $this.html(ago); + el.textContent = ago; }); } diff --git a/framework/core/js/src/common/utils/popperMobileModifier.ts b/framework/core/js/src/common/utils/popperMobileModifier.ts new file mode 100644 index 0000000000..7b12893720 --- /dev/null +++ b/framework/core/js/src/common/utils/popperMobileModifier.ts @@ -0,0 +1,20 @@ +import { type Modifier } from '@popperjs/core'; + +export default { + name: 'responsiveMobile', + enabled: true, + phase: 'beforeWrite', + fn({ state }) { + const screen = getComputedStyle(state.elements.popper).getPropertyValue('--flarum-screen'); + if (screen == 'phone') { + state.styles.popper = { + margin: null as unknown as string, + position: null as unknown as string, + left: null as unknown as string, + top: null as unknown as string, + bottom: null as unknown as string, + transform: null as unknown as string, + }; + } + }, +} satisfies Modifier; diff --git a/framework/core/js/src/common/utils/scrollEnd.ts b/framework/core/js/src/common/utils/scrollEnd.ts new file mode 100644 index 0000000000..60c1e52ca2 --- /dev/null +++ b/framework/core/js/src/common/utils/scrollEnd.ts @@ -0,0 +1,24 @@ +/** + * A utility function that waits for the scroll to end. + * Due to lack of support from some browsers, this is a workaround for `scrollend` event. + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollTo#behavior + * @see https://caniuse.com/mdn-api_element_scrollend_event + * @param container The container element to wait scroll end on. + * @returns A promise that resolves when the scroll is ended. + */ +export default function scrollEnd(container: HTMLElement): Promise { + let lastTop = container.scrollTop; + + return new Promise((resolve) => { + const animFrame = () => { + if (lastTop === container.scrollTop) { + resolve(); + } else { + requestAnimationFrame(animFrame); + lastTop = container.scrollTop; + } + }; + requestAnimationFrame(animFrame); + }); +} diff --git a/framework/core/js/src/forum/ForumApplication.tsx b/framework/core/js/src/forum/ForumApplication.tsx index 4bd195ca15..b9d2e1a9eb 100644 --- a/framework/core/js/src/forum/ForumApplication.tsx +++ b/framework/core/js/src/forum/ForumApplication.tsx @@ -137,9 +137,11 @@ export default class ForumApplication extends Application { }); if (isSafariMobile()) { - $(() => { - $('.App').addClass('mobile-safari'); - }); + const callback = () => { + document.querySelector('.App')?.classList.add('mobile-safari'); + }; + document.addEventListener('DOMContentLoaded', callback); + if (document.readyState != 'loading') callback(); } } diff --git a/framework/core/js/src/forum/components/AbstractPost.tsx b/framework/core/js/src/forum/components/AbstractPost.tsx index 0b261cea00..aac96e735d 100644 --- a/framework/core/js/src/forum/components/AbstractPost.tsx +++ b/framework/core/js/src/forum/components/AbstractPost.tsx @@ -66,8 +66,6 @@ export default abstract class AbstractPost this.$('.Post-controls').addClass('open')} - onhide={() => this.$('.Post-controls').removeClass('open')} accessibleToggleLabel={app.translator.trans('core.forum.post_controls.toggle_dropdown_accessible_label')} > {controls} @@ -92,10 +90,9 @@ export default abstract class AbstractPost) { super.onupdate(vnode); - const $actions = this.$('.Post-actions'); - const $controls = this.$('.Post-controls'); - - $actions.toggleClass('openWithin', $controls.hasClass('open')); + this.element + .querySelector('.Post-actions') + ?.classList.toggle('openWithin', this.element.querySelector('.Post-controls')?.classList.contains('open')); } elementAttrs(): Record { diff --git a/framework/core/js/src/forum/components/AffixedSidebar.js b/framework/core/js/src/forum/components/AffixedSidebar.js index 3cba6e88ef..8632edc0d2 100644 --- a/framework/core/js/src/forum/components/AffixedSidebar.js +++ b/framework/core/js/src/forum/components/AffixedSidebar.js @@ -1,15 +1,14 @@ import Component from '../../common/Component'; +import heightWithMargin from '../../common/utils/heightWithMargin'; /** - * The `AffixedSidebar` component uses Bootstrap's "affix" plugin to keep a + * The `AffixedSidebar` component uses sticky position to keep a * sidebar navigation at the top of the viewport when scrolling. * * ### Children * * The component must wrap an element that itself wraps an
      element, which * will be "affixed". - * - * @see https://getbootstrap.com/docs/3.4/javascript/#affix */ export default class AffixedSidebar extends Component { view(vnode) { @@ -19,35 +18,36 @@ export default class AffixedSidebar extends Component { oncreate(vnode) { super.oncreate(vnode); - // Register the affix plugin to execute on every window resize (and trigger) + // Register the affix to execute on every window resize (and trigger) this.boundOnresize = this.onresize.bind(this); - $(window).on('resize', this.boundOnresize).resize(); + window.addEventListener('resize', this.boundOnresize); + window.dispatchEvent(new Event('resize')); } onremove(vnode) { super.onremove(vnode); - $(window).off('resize', this.boundOnresize); + window.removeEventListener('resize', this.boundOnresize); } onresize() { - const $sidebar = this.$(); - const $header = $('#header'); - const $footer = $('#footer'); - const $affixElement = $sidebar.find('> ul'); - - $(window).off('.affix'); - $affixElement.removeClass('affix affix-top affix-bottom').removeData('bs.affix'); + const header = document.getElementById('header'); + const affixElement = this.element.querySelector(':scope > ul'); + const pageSidebar = this.element.closest('.Page-sidebar'); // Don't affix the sidebar if it is taller than the viewport (otherwise // there would be no way to scroll through its content). - if ($sidebar.outerHeight(true) > $(window).height() - $header.outerHeight(true)) return; - - $affixElement.affix({ - offset: { - top: () => $sidebar.offset().top - $header.outerHeight(true) - parseInt($sidebar.css('margin-top'), 10), - bottom: () => (this.bottom = $footer.outerHeight(true)), - }, - }); + const enabled = heightWithMargin(this.element) <= window.innerHeight - heightWithMargin(header); + affixElement.classList.toggle('affix', enabled); + if (enabled) { + const top = heightWithMargin(header) + parseInt(getComputedStyle(pageSidebar ?? this.element).marginTop, 10); + affixElement.style.position = 'sticky'; + affixElement.style.top = top + 'px'; + this.element.style.display = 'initial'; // Workaround for sticky not working + } else { + affixElement.style.position = ''; + affixElement.style.top = ''; + this.element.style.display = ''; + } } } diff --git a/framework/core/js/src/forum/components/AvatarEditor.js b/framework/core/js/src/forum/components/AvatarEditor.js index fd1da14226..c40ae7968e 100644 --- a/framework/core/js/src/forum/components/AvatarEditor.js +++ b/framework/core/js/src/forum/components/AvatarEditor.js @@ -149,15 +149,15 @@ export default class AvatarEditor extends Component { // Create a hidden HTML input element and click on it so the user can select // an avatar file. Once they have, we will upload it via the API. - const $input = $(''); - - $input - .appendTo('body') - .hide() - .click() - .on('input', (e) => { - this.upload($(e.target)[0].files[0]); - }); + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.jpg, .jpeg, .png, .bmp, .gif'; + input.style.display = 'none'; + input.click(); + input.addEventListener('input', (e) => { + this.upload(e.target.files[0]); + }); + document.body.append(input); } /** diff --git a/framework/core/js/src/forum/components/CommentPost.js b/framework/core/js/src/forum/components/CommentPost.js index ab17245794..beac77418d 100644 --- a/framework/core/js/src/forum/components/CommentPost.js +++ b/framework/core/js/src/forum/components/CommentPost.js @@ -82,11 +82,11 @@ export default class CommentPost extends Post { // all of the