Skip to content

Add types to various low-level functions #31781

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Aug 10, 2024
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 18 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -107,6 +107,7 @@
"stylelint-declaration-strict-value": "1.10.6",
"stylelint-value-no-unknown-custom-properties": "6.0.1",
"svgo": "3.3.2",
"type-fest": "4.23.0",
"updates": "16.3.7",
"vite-string-plugin": "1.3.4",
"vitest": "2.0.5"
9 changes: 9 additions & 0 deletions types.d.ts
Original file line number Diff line number Diff line change
@@ -3,6 +3,11 @@ declare module '*.svg' {
export default value;
}

declare module '*.css' {
const value: string;
export default value;
}

declare let __webpack_public_path__: string;

interface Window {
@@ -20,3 +25,7 @@ declare module 'htmx.org/dist/htmx.esm.js' {
const value = await import('htmx.org');
export default value;
}

interface Element {
_tippy: import('tippy.js').Instance;
}
2 changes: 1 addition & 1 deletion web_src/js/features/dropzone.ts
Original file line number Diff line number Diff line change
@@ -52,7 +52,7 @@ function addCopyLink(file) {
copyLinkEl.addEventListener('click', async (e) => {
e.preventDefault();
const success = await clippie(generateMarkdownLinkForAttachment(file));
showTemporaryTooltip(e.target, success ? i18n.copy_success : i18n.copy_error);
showTemporaryTooltip(e.target as Element, success ? i18n.copy_success : i18n.copy_error);
});
file.previewTemplate.append(copyLinkEl);
}
2 changes: 1 addition & 1 deletion web_src/js/features/repo-issue-list.ts
Original file line number Diff line number Diff line change
@@ -196,7 +196,7 @@ async function initIssuePinSort() {

createSortable(pinDiv, {
group: 'shared',
onEnd: pinMoveEnd,
onEnd: pinMoveEnd, // eslint-disable-line @typescript-eslint/no-misused-promises
});
}

6 changes: 3 additions & 3 deletions web_src/js/features/repo-projects.ts
Original file line number Diff line number Diff line change
@@ -60,7 +60,7 @@ async function initRepoProjectSortable() {
handle: '.project-column-header',
delayOnTouchOnly: true,
delay: 500,
onSort: async () => {
onSort: async () => { // eslint-disable-line @typescript-eslint/no-misused-promises
boardColumns = mainBoard.querySelectorAll('.project-column');

const columnSorting = {
@@ -84,8 +84,8 @@ async function initRepoProjectSortable() {
const boardCardList = boardColumn.querySelectorAll('.cards')[0];
createSortable(boardCardList, {
group: 'shared',
onAdd: moveIssue,
onUpdate: moveIssue,
onAdd: moveIssue, // eslint-disable-line @typescript-eslint/no-misused-promises
onUpdate: moveIssue, // eslint-disable-line @typescript-eslint/no-misused-promises
delayOnTouchOnly: true,
delay: 500,
});
2 changes: 1 addition & 1 deletion web_src/js/features/stopwatch.ts
Original file line number Diff line number Diff line change
@@ -27,7 +27,7 @@ export function initStopwatch() {
stopwatchEl.removeAttribute('href'); // intended for noscript mode only

createTippy(stopwatchEl, {
content: stopwatchPopup.cloneNode(true),
content: stopwatchPopup.cloneNode(true) as Element,
placement: 'bottom-end',
trigger: 'click',
maxWidth: 'none',
14 changes: 9 additions & 5 deletions web_src/js/modules/dirauto.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import {isDocumentFragmentOrElementNode} from '../utils/dom.ts';

type DirElement = HTMLInputElement | HTMLTextAreaElement;

// for performance considerations, it only uses performant syntax
function attachDirAuto(el) {
function attachDirAuto(el: DirElement) {
if (el.type !== 'hidden' &&
el.type !== 'checkbox' &&
el.type !== 'radio' &&
@@ -18,10 +20,12 @@ export function initDirAuto() {
const mutation = mutationList[i];
const len = mutation.addedNodes.length;
for (let i = 0; i < len; i++) {
const addedNode = mutation.addedNodes[i];
const addedNode = mutation.addedNodes[i] as HTMLElement;
if (!isDocumentFragmentOrElementNode(addedNode)) continue;
if (addedNode.nodeName === 'INPUT' || addedNode.nodeName === 'TEXTAREA') attachDirAuto(addedNode);
const children = addedNode.querySelectorAll('input, textarea');
if (addedNode.nodeName === 'INPUT' || addedNode.nodeName === 'TEXTAREA') {
attachDirAuto(addedNode as DirElement);
}
const children = addedNode.querySelectorAll<DirElement>('input, textarea');
const len = children.length;
for (let childIdx = 0; childIdx < len; childIdx++) {
attachDirAuto(children[childIdx]);
@@ -30,7 +34,7 @@ export function initDirAuto() {
}
});

const docNodes = document.querySelectorAll('input, textarea');
const docNodes = document.querySelectorAll<DirElement>('input, textarea');
const len = docNodes.length;
for (let i = 0; i < len; i++) {
attachDirAuto(docNodes[i]);
6 changes: 4 additions & 2 deletions web_src/js/modules/sortable.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
export async function createSortable(el, opts = {}) {
import type {SortableOptions} from 'sortablejs';

export async function createSortable(el, opts: {handle?: string} & SortableOptions = {}) {
const {Sortable} = await import(/* webpackChunkName: "sortablejs" */'sortablejs');

return new Sortable(el, {
@@ -15,5 +17,5 @@ export async function createSortable(el, opts = {}) {
opts.onUnchoose?.(e);
},
...opts,
});
} satisfies SortableOptions);
}
3 changes: 2 additions & 1 deletion web_src/js/modules/stores.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {reactive} from 'vue';
import type {Reactive} from 'vue';

let diffTreeStoreReactive;
let diffTreeStoreReactive: Reactive<Record<string, any>>;
export function diffTreeStore() {
if (!diffTreeStoreReactive) {
diffTreeStoreReactive = reactive(window.config.pageData.diffFileInfo);
47 changes: 24 additions & 23 deletions web_src/js/modules/tippy.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,38 @@
import tippy, {followCursor} from 'tippy.js';
import {isDocumentFragmentOrElementNode} from '../utils/dom.ts';
import {formatDatetime} from '../utils/time.ts';
import type {Content, Instance, Props} from 'tippy.js';

const visibleInstances = new Set();
type TippyOpts = {
role?: string,
theme?: 'default' | 'tooltip' | 'menu' | 'box-with-header' | 'bare',
} & Partial<Props>;

const visibleInstances = new Set<Instance>();
const arrowSvg = `<svg width="16" height="7"><path d="m0 7 8-7 8 7Z" class="tippy-svg-arrow-outer"/><path d="m0 8 8-7 8 7Z" class="tippy-svg-arrow-inner"/></svg>`;

export function createTippy(target, opts = {}) {
export function createTippy(target: Element, opts: TippyOpts = {}) {
// the callback functions should be destructured from opts,
// because we should use our own wrapper functions to handle them, do not let the user override them
const {onHide, onShow, onDestroy, role, theme, arrow, ...other} = opts;

const instance = tippy(target, {
const instance: Instance = tippy(target, {
appendTo: document.body,
animation: false,
allowHTML: false,
hideOnClick: false,
interactiveBorder: 20,
ignoreAttributes: true,
maxWidth: 500, // increase over default 350px
onHide: (instance) => {
onHide: (instance: Instance) => {
visibleInstances.delete(instance);
return onHide?.(instance);
},
onDestroy: (instance) => {
onDestroy: (instance: Instance) => {
visibleInstances.delete(instance);
return onDestroy?.(instance);
},
onShow: (instance) => {
onShow: (instance: Instance) => {
// hide other tooltip instances so only one tooltip shows at a time
for (const visibleInstance of visibleInstances) {
if (visibleInstance.props.role === 'tooltip') {
@@ -43,7 +49,7 @@ export function createTippy(target, opts = {}) {
theme: theme || role || 'default',
plugins: [followCursor],
...other,
});
} satisfies Partial<Props>);

if (role === 'menu') {
target.setAttribute('aria-haspopup', 'true');
@@ -58,12 +64,8 @@ export function createTippy(target, opts = {}) {
* If the target element has no content, then no tooltip will be attached, and it returns null.
*
* Note: "tooltip" doesn't equal to "tippy". "tooltip" means a auto-popup content, it just uses tippy as the implementation.
*
* @param target {HTMLElement}
* @param content {null|string}
* @returns {null|tippy}
*/
function attachTooltip(target, content = null) {
function attachTooltip(target: Element, content: Content = null) {
switchTitleToTooltip(target);

content = content ?? target.getAttribute('data-tooltip-content');
@@ -84,7 +86,7 @@ function attachTooltip(target, content = null) {
placement: target.getAttribute('data-tooltip-placement') || 'top-start',
followCursor: target.getAttribute('data-tooltip-follow-cursor') || false,
...(target.getAttribute('data-tooltip-interactive') === 'true' ? {interactive: true, aria: {content: 'describedby', expanded: false}} : {}),
};
} as TippyOpts;

if (!target._tippy) {
createTippy(target, props);
@@ -94,7 +96,7 @@ function attachTooltip(target, content = null) {
return target._tippy;
}

function switchTitleToTooltip(target) {
function switchTitleToTooltip(target: Element) {
let title = target.getAttribute('title');
if (title) {
// apply custom formatting to relative-time's tooltips
@@ -118,16 +120,15 @@ function switchTitleToTooltip(target) {
* According to https://www.w3.org/TR/DOM-Level-3-Events/#events-mouseevent-event-order , mouseover event is fired before mouseenter event
* Some browsers like PaleMoon don't support "addEventListener('mouseenter', capture)"
* The tippy by default uses "mouseenter" event to show, so we use "mouseover" event to switch to tippy
* @param e {Event}
*/
function lazyTooltipOnMouseHover(e) {
function lazyTooltipOnMouseHover(e: MouseEvent) {
e.target.removeEventListener('mouseover', lazyTooltipOnMouseHover, true);
attachTooltip(this);
}

// Activate the tooltip for current element.
// If the element has no aria-label, use the tooltip content as aria-label.
function attachLazyTooltip(el) {
function attachLazyTooltip(el: Element) {
el.addEventListener('mouseover', lazyTooltipOnMouseHover, {capture: true});

// meanwhile, if the element has no aria-label, use the tooltip content as aria-label
@@ -140,15 +141,15 @@ function attachLazyTooltip(el) {
}

// Activate the tooltip for all children elements.
function attachChildrenLazyTooltip(target) {
for (const el of target.querySelectorAll('[data-tooltip-content]')) {
function attachChildrenLazyTooltip(target: Element) {
for (const el of target.querySelectorAll<Element>('[data-tooltip-content]')) {
attachLazyTooltip(el);
}
}

export function initGlobalTooltips() {
// use MutationObserver to detect new "data-tooltip-content" elements added to the DOM, or attributes changed
const observerConnect = (observer) => observer.observe(document, {
const observerConnect = (observer: MutationObserver) => observer.observe(document, {
subtree: true,
childList: true,
attributeFilter: ['data-tooltip-content', 'title'],
@@ -159,15 +160,15 @@ export function initGlobalTooltips() {
for (const mutation of [...mutationList, ...pending]) {
if (mutation.type === 'childList') {
// mainly for Vue components and AJAX rendered elements
for (const el of mutation.addedNodes) {
for (const el of mutation.addedNodes as NodeListOf<Element>) {
if (!isDocumentFragmentOrElementNode(el)) continue;
attachChildrenLazyTooltip(el);
if (el.hasAttribute('data-tooltip-content')) {
attachLazyTooltip(el);
}
}
} else if (mutation.type === 'attributes') {
attachTooltip(mutation.target);
attachTooltip(mutation.target as Element);
}
}
observerConnect(observer);
@@ -177,7 +178,7 @@ export function initGlobalTooltips() {
attachChildrenLazyTooltip(document.documentElement);
}

export function showTemporaryTooltip(target, content) {
export function showTemporaryTooltip(target: Element, content: Content) {
// if the target is inside a dropdown, don't show the tooltip because when the dropdown
// closes, the tippy would be pushed unsightly to the top-left of the screen like seen
// on the issue comment menu.
11 changes: 6 additions & 5 deletions web_src/js/utils/color.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,27 @@
import tinycolor from 'tinycolor2';
import type {ColorInput} from 'tinycolor2';

// Returns relative luminance for a SRGB color - https://en.wikipedia.org/wiki/Relative_luminance
// Keep this in sync with modules/util/color.go
function getRelativeLuminance(color) {
function getRelativeLuminance(color: ColorInput) {
const {r, g, b} = tinycolor(color).toRgb();
return (0.2126729 * r + 0.7151522 * g + 0.072175 * b) / 255;
}

function useLightText(backgroundColor) {
function useLightText(backgroundColor: ColorInput) {
return getRelativeLuminance(backgroundColor) < 0.453;
}

// Given a background color, returns a black or white foreground color that the highest
// contrast ratio. In the future, the APCA contrast function, or CSS `contrast-color` will be better.
// https://github.com/color-js/color.js/blob/eb7b53f7a13bb716ec8b28c7a56f052cd599acd9/src/contrast/APCA.js#L42
export function contrastColor(backgroundColor) {
export function contrastColor(backgroundColor: ColorInput) {
return useLightText(backgroundColor) ? '#fff' : '#000';
}

function resolveColors(obj) {
function resolveColors(obj: Record<string, string>) {
const styles = window.getComputedStyle(document.documentElement);
const getColor = (name) => styles.getPropertyValue(name).trim();
const getColor = (name: string) => styles.getPropertyValue(name).trim();
return Object.fromEntries(Object.entries(obj).map(([key, value]) => [key, getColor(value)]));
}

75 changes: 44 additions & 31 deletions web_src/js/utils/dom.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
import {debounce} from 'throttle-debounce';
import type {Promisable} from 'type-fest';
import type $ from 'jquery';

function elementsCall(el, func, ...args) {
type ElementArg = Element | string | NodeListOf<Element> | Array<Element> | ReturnType<typeof $>;
type ElementsCallback = (el: Element) => Promisable<any>;
type ElementsCallbackWithArgs = (el: Element, ...args: any[]) => Promisable<any>;
type IterableElements = NodeListOf<Element> | Array<Element>;

function elementsCall(el: ElementArg, func: ElementsCallbackWithArgs, ...args: any[]) {
if (typeof el === 'string' || el instanceof String) {
el = document.querySelectorAll(el);
el = document.querySelectorAll(el as string);
}
if (el instanceof Node) {
func(el, ...args);
} else if (el.length !== undefined) {
// this works for: NodeList, HTMLCollection, Array, jQuery
for (const e of el) {
for (const e of (el as IterableElements)) {
func(e, ...args);
}
} else {
@@ -17,10 +24,10 @@ function elementsCall(el, func, ...args) {
}

/**
* @param el string (selector), Node, NodeList, HTMLCollection, Array or jQuery
* @param el Element
* @param force force=true to show or force=false to hide, undefined to toggle
*/
function toggleShown(el, force) {
function toggleShown(el: Element, force: boolean) {
if (force === true) {
el.classList.remove('tw-hidden');
} else if (force === false) {
@@ -32,26 +39,26 @@ function toggleShown(el, force) {
}
}

export function showElem(el) {
export function showElem(el: ElementArg) {
elementsCall(el, toggleShown, true);
}

export function hideElem(el) {
export function hideElem(el: ElementArg) {
elementsCall(el, toggleShown, false);
}

export function toggleElem(el, force) {
export function toggleElem(el: ElementArg, force?: boolean) {
elementsCall(el, toggleShown, force);
}

export function isElemHidden(el) {
const res = [];
export function isElemHidden(el: ElementArg) {
const res: boolean[] = [];
elementsCall(el, (e) => res.push(e.classList.contains('tw-hidden')));
if (res.length > 1) throw new Error(`isElemHidden doesn't work for multiple elements`);
return res[0];
}

function applyElemsCallback(elems, fn) {
function applyElemsCallback(elems: IterableElements, fn?: ElementsCallback) {
if (fn) {
for (const el of elems) {
fn(el);
@@ -60,20 +67,22 @@ function applyElemsCallback(elems, fn) {
return elems;
}

export function queryElemSiblings(el, selector = '*', fn) {
return applyElemsCallback(Array.from(el.parentNode.children).filter((child) => child !== el && child.matches(selector)), fn);
export function queryElemSiblings(el: Element, selector = '*', fn?: ElementsCallback) {
return applyElemsCallback(Array.from(el.parentNode.children).filter((child: Element) => {
return child !== el && child.matches(selector);
}), fn);
}

// it works like jQuery.children: only the direct children are selected
export function queryElemChildren(parent, selector = '*', fn) {
export function queryElemChildren(parent: Element | ParentNode, selector = '*', fn?: ElementsCallback) {
return applyElemsCallback(parent.querySelectorAll(`:scope > ${selector}`), fn);
}

export function queryElems(selector, fn) {
export function queryElems(selector: string, fn?: ElementsCallback) {
return applyElemsCallback(document.querySelectorAll(selector), fn);
}

export function onDomReady(cb) {
export function onDomReady(cb: () => Promisable<void>) {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', cb);
} else {
@@ -83,7 +92,7 @@ export function onDomReady(cb) {

// checks whether an element is owned by the current document, and whether it is a document fragment or element node
// if it is, it means it is a "normal" element managed by us, which can be modified safely.
export function isDocumentFragmentOrElementNode(el) {
export function isDocumentFragmentOrElementNode(el: Element) {
try {
return el.ownerDocument === document && el.nodeType === Node.ELEMENT_NODE || el.nodeType === Node.DOCUMENT_FRAGMENT_NODE;
} catch {
@@ -108,12 +117,15 @@ export function isDocumentFragmentOrElementNode(el) {
// The above copyright notice and this permission notice shall be
// included in all copies or substantial portions of the Software.
// ---------------------------------------------------------------------
export function autosize(textarea, {viewportMarginBottom = 0} = {}) {
export function autosize(textarea: HTMLTextAreaElement, {viewportMarginBottom = 0}: {viewportMarginBottom?: number} = {}) {
let isUserResized = false;
// lastStyleHeight and initialStyleHeight are CSS values like '100px'
let lastMouseX, lastMouseY, lastStyleHeight, initialStyleHeight;
let lastMouseX: number;
let lastMouseY: number;
let lastStyleHeight: string;
let initialStyleHeight: string;

function onUserResize(event) {
function onUserResize(event: MouseEvent) {
if (isUserResized) return;
if (lastMouseX !== event.clientX || lastMouseY !== event.clientY) {
const newStyleHeight = textarea.style.height;
@@ -133,7 +145,7 @@ export function autosize(textarea, {viewportMarginBottom = 0} = {}) {

while (el !== document.body && el !== null) {
offsetTop += el.offsetTop || 0;
el = el.offsetParent;
el = el.offsetParent as HTMLTextAreaElement;
}

const top = offsetTop - document.defaultView.scrollY;
@@ -213,14 +225,15 @@ export function autosize(textarea, {viewportMarginBottom = 0} = {}) {
};
}

export function onInputDebounce(fn) {
export function onInputDebounce(fn: () => Promisable<any>) {
return debounce(300, fn);
}

type LoadableElement = HTMLEmbedElement | HTMLIFrameElement | HTMLImageElement | HTMLScriptElement | HTMLTrackElement;

// Set the `src` attribute on an element and returns a promise that resolves once the element
// has loaded or errored. Suitable for all elements mention in:
// https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/load_event
export function loadElem(el, src) {
// has loaded or errored.
export function loadElem(el: LoadableElement, src: string) {
return new Promise((resolve) => {
el.addEventListener('load', () => resolve(true), {once: true});
el.addEventListener('error', () => resolve(false), {once: true});
@@ -256,14 +269,14 @@ export function initSubmitEventPolyfill() {
* @param {HTMLElement} element The element to check.
* @returns {boolean} True if the element is visible.
*/
export function isElemVisible(element) {
export function isElemVisible(element: HTMLElement) {
if (!element) return false;

return Boolean(element.offsetWidth || element.offsetHeight || element.getClientRects().length);
}

// replace selected text in a textarea while preserving editor history, e.g. CTRL-Z works after this
export function replaceTextareaSelection(textarea, text) {
export function replaceTextareaSelection(textarea: HTMLTextAreaElement, text: string) {
const before = textarea.value.slice(0, textarea.selectionStart ?? undefined);
const after = textarea.value.slice(textarea.selectionEnd ?? undefined);
let success = true;
@@ -287,13 +300,13 @@ export function replaceTextareaSelection(textarea, text) {
}

// Warning: Do not enter any unsanitized variables here
export function createElementFromHTML(htmlString) {
export function createElementFromHTML(htmlString: string) {
const div = document.createElement('div');
div.innerHTML = htmlString.trim();
return div.firstChild;
return div.firstChild as Element;
}

export function createElementFromAttrs(tagName, attrs) {
export function createElementFromAttrs(tagName: string, attrs: Record<string, any>) {
const el = document.createElement(tagName);
for (const [key, value] of Object.entries(attrs)) {
if (value === undefined || value === null) continue;
@@ -307,7 +320,7 @@ export function createElementFromAttrs(tagName, attrs) {
return el;
}

export function animateOnce(el, animationClassName) {
export function animateOnce(el: Element, animationClassName: string): Promise<void> {
return new Promise((resolve) => {
el.addEventListener('animationend', function onAnimationEnd() {
el.classList.remove(animationClassName);