diff options
Diffstat (limited to 'web_src')
-rw-r--r-- | web_src/css/editor/combomarkdowneditor.css | 64 | ||||
-rw-r--r-- | web_src/css/features/expander.css | 96 | ||||
-rw-r--r-- | web_src/css/features/tribute.css | 32 | ||||
-rw-r--r-- | web_src/css/index.css | 2 | ||||
-rw-r--r-- | web_src/js/features/common-button.test.ts | 14 | ||||
-rw-r--r-- | web_src/js/features/common-button.ts | 34 | ||||
-rw-r--r-- | web_src/js/features/common-issue-list.ts | 6 | ||||
-rw-r--r-- | web_src/js/features/comp/TextExpander.ts | 1 | ||||
-rw-r--r-- | web_src/js/features/repo-issue-list.ts | 6 | ||||
-rw-r--r-- | web_src/js/utils/dom.test.ts | 6 | ||||
-rw-r--r-- | web_src/js/utils/dom.ts | 49 |
11 files changed, 168 insertions, 142 deletions
diff --git a/web_src/css/editor/combomarkdowneditor.css b/web_src/css/editor/combomarkdowneditor.css index 835286b795..046010c6c8 100644 --- a/web_src/css/editor/combomarkdowneditor.css +++ b/web_src/css/editor/combomarkdowneditor.css @@ -100,67 +100,3 @@ border-bottom: 1px solid var(--color-secondary); padding-bottom: 1rem; } - -text-expander { - display: block; - position: relative; -} - -text-expander .suggestions { - position: absolute; - min-width: 180px; - padding: 0; - margin-top: 24px; - list-style: none; - background: var(--color-box-body); - border-radius: var(--border-radius); - border: 1px solid var(--color-secondary); - box-shadow: 0 .5rem 1rem var(--color-shadow); - z-index: 100; /* needs to be > 20 to be on top of dropzone's .dz-details */ -} - -text-expander .suggestions li { - display: flex; - align-items: center; - cursor: pointer; - padding: 4px 8px; - font-weight: var(--font-weight-medium); -} - -text-expander .suggestions li + li { - border-top: 1px solid var(--color-secondary-alpha-40); -} - -text-expander .suggestions li:first-child { - border-radius: var(--border-radius) var(--border-radius) 0 0; -} - -text-expander .suggestions li:last-child { - border-radius: 0 0 var(--border-radius) var(--border-radius); -} - -text-expander .suggestions li:only-child { - border-radius: var(--border-radius); -} - -text-expander .suggestions li:hover { - background: var(--color-hover); -} - -text-expander .suggestions .fullname { - font-weight: var(--font-weight-normal); - margin-left: 4px; - color: var(--color-text-light-1); -} - -text-expander .suggestions li[aria-selected="true"], -text-expander .suggestions li[aria-selected="true"] span { - background: var(--color-primary); - color: var(--color-primary-contrast); -} - -text-expander .suggestions img { - width: 24px; - height: 24px; - margin-right: 8px; -} diff --git a/web_src/css/features/expander.css b/web_src/css/features/expander.css new file mode 100644 index 0000000000..f560b2a9fd --- /dev/null +++ b/web_src/css/features/expander.css @@ -0,0 +1,96 @@ +text-expander .suggestions, +.tribute-container { + position: absolute; + max-height: min(300px, 95vh); + max-width: min(500px, 95vw); + overflow-x: hidden; + overflow-y: auto; + white-space: nowrap; + background: var(--color-menu); + box-shadow: 0 6px 18px var(--color-shadow); + border-radius: var(--border-radius); + border: 1px solid var(--color-secondary); + z-index: 100; /* needs to be > 20 to be on top of dropzone's .dz-details */ +} + +text-expander { + display: block; + position: relative; +} + +text-expander .suggestions { + padding: 0; + margin-top: 24px; + list-style: none; +} + +text-expander .suggestions li, +.tribute-item { + display: flex; + align-items: center; + cursor: pointer; + gap: 6px; + font-weight: var(--font-weight-medium); +} + +text-expander .suggestions li, +.tribute-container li { + padding: 3px 6px; +} + +text-expander .suggestions li + li, +.tribute-container li + li { + border-top: 1px solid var(--color-secondary); +} + +text-expander .suggestions li:first-child { + border-radius: var(--border-radius) var(--border-radius) 0 0; +} + +text-expander .suggestions li:last-child { + border-radius: 0 0 var(--border-radius) var(--border-radius); +} + +text-expander .suggestions li:only-child { + border-radius: var(--border-radius); +} + +text-expander .suggestions .fullname, +.tribute-container li .fullname { + font-weight: var(--font-weight-normal); + color: var(--color-text-light-1); + overflow: hidden; + text-overflow: ellipsis; +} + +text-expander .suggestions li:hover, +text-expander .suggestions li:hover *, +text-expander .suggestions li[aria-selected="true"], +text-expander .suggestions li[aria-selected="true"] *, +.tribute-container li.highlight, +.tribute-container li.highlight * { + background: var(--color-primary); + color: var(--color-primary-contrast); +} + +text-expander .suggestions img, +.tribute-item img { + width: 21px; + height: 21px; + object-fit: contain; + aspect-ratio: 1; +} + +.tribute-container { + display: block; +} + +.tribute-container ul { + margin: 0; + padding: 0; + list-style: none; +} + +.tribute-container li.no-match { + cursor: default; +} diff --git a/web_src/css/features/tribute.css b/web_src/css/features/tribute.css deleted file mode 100644 index 99a026b9bc..0000000000 --- a/web_src/css/features/tribute.css +++ /dev/null @@ -1,32 +0,0 @@ -@import "tributejs/dist/tribute.css"; - -.tribute-container { - box-shadow: 0 0.25rem 0.5rem rgba(0, 0, 0, 0.25); - border-radius: var(--border-radius); -} - -.tribute-container ul { - margin-top: 0 !important; - background: var(--color-body) !important; -} - -.tribute-container li { - padding: 3px 0.5rem !important; -} - -.tribute-container li span.fullname { - font-weight: var(--font-weight-normal); - font-size: 0.8rem; -} - -.tribute-container li.highlight, -.tribute-container li:hover { - background: var(--color-primary) !important; - color: var(--color-primary-contrast) !important; -} - -.tribute-item { - display: flex; - align-items: center; - gap: 6px; -} diff --git a/web_src/css/index.css b/web_src/css/index.css index 84795d6d27..c20aa028e4 100644 --- a/web_src/css/index.css +++ b/web_src/css/index.css @@ -39,7 +39,7 @@ @import "./features/imagediff.css"; @import "./features/codeeditor.css"; @import "./features/projects.css"; -@import "./features/tribute.css"; +@import "./features/expander.css"; @import "./features/cropper.css"; @import "./features/console.css"; diff --git a/web_src/js/features/common-button.test.ts b/web_src/js/features/common-button.test.ts new file mode 100644 index 0000000000..f41bafbc79 --- /dev/null +++ b/web_src/js/features/common-button.test.ts @@ -0,0 +1,14 @@ +import {assignElementProperty} from './common-button.ts'; + +test('assignElementProperty', () => { + const elForm = document.createElement('form'); + assignElementProperty(elForm, 'action', '/test-link'); + expect(elForm.action).contains('/test-link'); // the DOM always returns absolute URL + assignElementProperty(elForm, 'text-content', 'dummy'); + expect(elForm.textContent).toBe('dummy'); + + const elInput = document.createElement('input'); + expect(elInput.readOnly).toBe(false); + assignElementProperty(elInput, 'read-only', 'true'); + expect(elInput.readOnly).toBe(true); +}); diff --git a/web_src/js/features/common-button.ts b/web_src/js/features/common-button.ts index 003bfbce5d..ae399e48b3 100644 --- a/web_src/js/features/common-button.ts +++ b/web_src/js/features/common-button.ts @@ -1,5 +1,5 @@ import {POST} from '../modules/fetch.ts'; -import {addDelegatedEventListener, hideElem, showElem, toggleElem} from '../utils/dom.ts'; +import {addDelegatedEventListener, hideElem, isElemVisible, showElem, toggleElem} from '../utils/dom.ts'; import {fomanticQuery} from '../modules/fomantic/base.ts'; import {camelize} from 'vue'; @@ -79,10 +79,11 @@ function onShowPanelClick(el: HTMLElement, e: MouseEvent) { // if it has "toggle" class, it toggles the panel e.preventDefault(); const sel = el.getAttribute('data-panel'); - if (el.classList.contains('toggle')) { - toggleElem(sel); - } else { - showElem(sel); + const elems = el.classList.contains('toggle') ? toggleElem(sel) : showElem(sel); + for (const elem of elems) { + if (isElemVisible(elem as HTMLElement)) { + elem.querySelector<HTMLElement>('[autofocus]')?.focus(); + } } } @@ -102,6 +103,21 @@ function onHidePanelClick(el: HTMLElement, e: MouseEvent) { throw new Error('no panel to hide'); // should never happen, otherwise there is a bug in code } +export function assignElementProperty(el: any, name: string, val: string) { + name = camelize(name); + const old = el[name]; + if (typeof old === 'boolean') { + el[name] = val === 'true'; + } else if (typeof old === 'number') { + el[name] = parseFloat(val); + } else if (typeof old === 'string') { + el[name] = val; + } else { + // in the future, we could introduce a better typing system like `data-modal-form.action:string="..."` + throw new Error(`cannot assign element property ${name} by value ${val}`); + } +} + function onShowModalClick(el: HTMLElement, e: MouseEvent) { // A ".show-modal" button will show a modal dialog defined by its "data-modal" attribute. // Each "data-modal-{target}" attribute will be filled to target element's value or text-content. @@ -109,7 +125,7 @@ function onShowModalClick(el: HTMLElement, e: MouseEvent) { // * Then, try to query '[name=target]' // * Then, try to query '.target' // * Then, try to query 'target' as HTML tag - // If there is a ".{attr}" part like "data-modal-form.action", then the form's "action" attribute will be set. + // If there is a ".{prop-name}" part like "data-modal-form.action", the "form" element's "action" property will be set, the "prop-name" will be camel-cased to "propName". e.preventDefault(); const modalSelector = el.getAttribute('data-modal'); const elModal = document.querySelector(modalSelector); @@ -122,7 +138,7 @@ function onShowModalClick(el: HTMLElement, e: MouseEvent) { } const attrTargetCombo = attrib.name.substring(modalAttrPrefix.length); - const [attrTargetName, attrTargetAttr] = attrTargetCombo.split('.'); + const [attrTargetName, attrTargetProp] = attrTargetCombo.split('.'); // try to find target by: "#target" -> "[name=target]" -> ".target" -> "<target> tag" const attrTarget = elModal.querySelector(`#${attrTargetName}`) || elModal.querySelector(`[name=${attrTargetName}]`) || @@ -133,8 +149,8 @@ function onShowModalClick(el: HTMLElement, e: MouseEvent) { continue; } - if (attrTargetAttr) { - (attrTarget as any)[camelize(attrTargetAttr)] = attrib.value; + if (attrTargetProp) { + assignElementProperty(attrTarget, attrTargetProp, attrib.value); } else if (attrTarget.matches('input, textarea')) { (attrTarget as HTMLInputElement | HTMLTextAreaElement).value = attrib.value; // FIXME: add more supports like checkbox } else { diff --git a/web_src/js/features/common-issue-list.ts b/web_src/js/features/common-issue-list.ts index e207364794..037529bd10 100644 --- a/web_src/js/features/common-issue-list.ts +++ b/web_src/js/features/common-issue-list.ts @@ -1,4 +1,4 @@ -import {isElemHidden, onInputDebounce, submitEventSubmitter, toggleElem} from '../utils/dom.ts'; +import {isElemVisible, onInputDebounce, submitEventSubmitter, toggleElem} from '../utils/dom.ts'; import {GET} from '../modules/fetch.ts'; const {appSubUrl} = window.config; @@ -28,7 +28,7 @@ export function parseIssueListQuickGotoLink(repoLink: string, searchText: string } export function initCommonIssueListQuickGoto() { - const goto = document.querySelector('#issue-list-quick-goto'); + const goto = document.querySelector<HTMLElement>('#issue-list-quick-goto'); if (!goto) return; const form = goto.closest('form'); @@ -37,7 +37,7 @@ export function initCommonIssueListQuickGoto() { form.addEventListener('submit', (e) => { // if there is no goto button, or the form is submitted by non-quick-goto elements, submit the form directly - let doQuickGoto = !isElemHidden(goto); + let doQuickGoto = isElemVisible(goto); const submitter = submitEventSubmitter(e); if (submitter !== form && submitter !== input && submitter !== goto) doQuickGoto = false; if (!doQuickGoto) return; diff --git a/web_src/js/features/comp/TextExpander.ts b/web_src/js/features/comp/TextExpander.ts index 5be234629d..2d79fe5029 100644 --- a/web_src/js/features/comp/TextExpander.ts +++ b/web_src/js/features/comp/TextExpander.ts @@ -97,6 +97,7 @@ export function initTextExpander(expander: TextExpanderElement) { li.append(img); const nameSpan = document.createElement('span'); + nameSpan.classList.add('name'); nameSpan.textContent = name; li.append(nameSpan); diff --git a/web_src/js/features/repo-issue-list.ts b/web_src/js/features/repo-issue-list.ts index 8cd4483357..3ea5fb70c0 100644 --- a/web_src/js/features/repo-issue-list.ts +++ b/web_src/js/features/repo-issue-list.ts @@ -1,5 +1,5 @@ import {updateIssuesMeta} from './repo-common.ts'; -import {toggleElem, isElemHidden, queryElems} from '../utils/dom.ts'; +import {toggleElem, queryElems, isElemVisible} from '../utils/dom.ts'; import {htmlEscape} from 'escape-goat'; import {confirmModal} from './comp/ConfirmModal.ts'; import {showErrorToast} from '../modules/toast.ts'; @@ -33,8 +33,8 @@ function initRepoIssueListCheckboxes() { toggleElem('#issue-filters', !anyChecked); toggleElem('#issue-actions', anyChecked); // there are two panels but only one select-all checkbox, so move the checkbox to the visible panel - const panels = document.querySelectorAll('#issue-filters, #issue-actions'); - const visiblePanel = Array.from(panels).find((el) => !isElemHidden(el)); + const panels = document.querySelectorAll<HTMLElement>('#issue-filters, #issue-actions'); + const visiblePanel = Array.from(panels).find((el) => isElemVisible(el)); const toolbarLeft = visiblePanel.querySelector('.issue-list-toolbar-left'); toolbarLeft.prepend(issueSelectAll); }; diff --git a/web_src/js/utils/dom.test.ts b/web_src/js/utils/dom.test.ts index 6a3af91556..057ea9808c 100644 --- a/web_src/js/utils/dom.test.ts +++ b/web_src/js/utils/dom.test.ts @@ -25,10 +25,14 @@ test('createElementFromAttrs', () => { }); test('querySingleVisibleElem', () => { - let el = createElementFromHTML('<div><span>foo</span></div>'); + let el = createElementFromHTML('<div></div>'); + expect(querySingleVisibleElem(el, 'span')).toBeNull(); + el = createElementFromHTML('<div><span>foo</span></div>'); expect(querySingleVisibleElem(el, 'span').textContent).toEqual('foo'); el = createElementFromHTML('<div><span style="display: none;">foo</span><span>bar</span></div>'); expect(querySingleVisibleElem(el, 'span').textContent).toEqual('bar'); + el = createElementFromHTML('<div><span class="some-class tw-hidden">foo</span><span>bar</span></div>'); + expect(querySingleVisibleElem(el, 'span').textContent).toEqual('bar'); el = createElementFromHTML('<div><span>foo</span><span>bar</span></div>'); expect(() => querySingleVisibleElem(el, 'span')).toThrowError('Expected exactly one visible element'); }); diff --git a/web_src/js/utils/dom.ts b/web_src/js/utils/dom.ts index 4386d38632..83a0d9c8df 100644 --- a/web_src/js/utils/dom.ts +++ b/web_src/js/utils/dom.ts @@ -9,24 +9,24 @@ type ElementsCallback<T extends Element> = (el: T) => Promisable<any>; type ElementsCallbackWithArgs = (el: Element, ...args: any[]) => Promisable<any>; export type DOMEvent<E extends Event, T extends Element = HTMLElement> = E & { target: Partial<T>; }; -function elementsCall(el: ElementArg, func: ElementsCallbackWithArgs, ...args: any[]) { +function elementsCall(el: ElementArg, func: ElementsCallbackWithArgs, ...args: any[]): ArrayLikeIterable<Element> { if (typeof el === 'string' || el instanceof String) { el = document.querySelectorAll(el as string); } if (el instanceof Node) { func(el, ...args); + return [el]; } else if (el.length !== undefined) { // this works for: NodeList, HTMLCollection, Array, jQuery - for (const e of (el as ArrayLikeIterable<Element>)) { - func(e, ...args); - } - } else { - throw new Error('invalid argument to be shown/hidden'); + const elems = el as ArrayLikeIterable<Element>; + for (const elem of elems) func(elem, ...args); + return elems; } + throw new Error('invalid argument to be shown/hidden'); } -export function toggleClass(el: ElementArg, className: string, force?: boolean) { - elementsCall(el, (e: Element) => { +export function toggleClass(el: ElementArg, className: string, force?: boolean): ArrayLikeIterable<Element> { + return elementsCall(el, (e: Element) => { if (force === true) { e.classList.add(className); } else if (force === false) { @@ -43,23 +43,16 @@ export function toggleClass(el: ElementArg, className: string, force?: boolean) * @param el ElementArg * @param force force=true to show or force=false to hide, undefined to toggle */ -export function toggleElem(el: ElementArg, force?: boolean) { - toggleClass(el, 'tw-hidden', force === undefined ? force : !force); -} - -export function showElem(el: ElementArg) { - toggleElem(el, true); +export function toggleElem(el: ElementArg, force?: boolean): ArrayLikeIterable<Element> { + return toggleClass(el, 'tw-hidden', force === undefined ? force : !force); } -export function hideElem(el: ElementArg) { - toggleElem(el, false); +export function showElem(el: ElementArg): ArrayLikeIterable<Element> { + return toggleElem(el, true); } -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]; +export function hideElem(el: ElementArg): ArrayLikeIterable<Element> { + return toggleElem(el, false); } function applyElemsCallback<T extends Element>(elems: ArrayLikeIterable<T>, fn?: ElementsCallback<T>): ArrayLikeIterable<T> { @@ -275,14 +268,12 @@ export function initSubmitEventPolyfill() { document.body.addEventListener('focus', submitEventPolyfillListener); } -/** - * Check if an element is visible, equivalent to jQuery's `:visible` pseudo. - * Note: This function doesn't account for all possible visibility scenarios. - */ -export function isElemVisible(element: HTMLElement): boolean { - if (!element) return false; - // checking element.style.display is not necessary for browsers, but it is required by some tests with happy-dom because happy-dom doesn't really do layout - return Boolean((element.offsetWidth || element.offsetHeight || element.getClientRects().length) && element.style.display !== 'none'); +export function isElemVisible(el: HTMLElement): boolean { + // Check if an element is visible, equivalent to jQuery's `:visible` pseudo. + // This function DOESN'T account for all possible visibility scenarios, its behavior is covered by the tests of "querySingleVisibleElem" + if (!el) return false; + // checking el.style.display is not necessary for browsers, but it is required by some tests with happy-dom because happy-dom doesn't really do layout + return !el.classList.contains('tw-hidden') && Boolean((el.offsetWidth || el.offsetHeight || el.getClientRects().length) && el.style.display !== 'none'); } // replace selected text in a textarea while preserving editor history, e.g. CTRL-Z works after this |