diff --git a/src/gui/choiceList/AddChoiceControls.svelte b/src/gui/choiceList/AddChoiceControls.svelte index 101c4d343..84b3d8ddd 100644 --- a/src/gui/choiceList/AddChoiceControls.svelte +++ b/src/gui/choiceList/AddChoiceControls.svelte @@ -54,20 +54,29 @@ }), ); } - // Anchor the menu under the trigger button. Using the button rect (rather - // than the event coordinates) makes this work for keyboard activation too - // and is robust to Svelte 5's event delegation (currentTarget is the - // delegated root, not the button). // Reflect open state for assistive tech (aria-expanded on the trigger). menuOpen = true; menu.onHide(() => { menuOpen = false; }); + // Anchor the menu under the trigger button. Using the button rect (rather + // than the event coordinates) makes this work for keyboard activation too + // and is robust to Svelte 5's event delegation (currentTarget is the + // delegated root, not the button). Passing the button's width with + // `overlap` keeps the menu under the button when it has to flip, instead + // of ending at the button's left edge. The primary button is the bar's + // rightmost action, so its menu prefers to right-align (`left`). const trigger = (evt.target as HTMLElement | null)?.closest("button"); if (trigger) { const rect = trigger.getBoundingClientRect(); - menu.showAtPosition({ x: rect.left, y: rect.bottom + 4 }); + menu.showAtPosition({ + x: rect.left, + y: rect.bottom + 4, + width: rect.width, + overlap: true, + left: !compact, + }); } else { menu.showAtMouseEvent(evt); } diff --git a/src/styles.css b/src/styles.css index a8b701d24..4905594ab 100644 --- a/src/styles.css +++ b/src/styles.css @@ -323,6 +323,18 @@ color: var(--text-muted); } +/* One control column for every row, so inputs line up however long their + labels are. Stacked rows (dates, narrow screens, discovery forms) override + the width below. */ +.quickAddModal.onePageInputModal .setting-item-control { + flex: none; + width: 60%; +} + +.quickAddModal.onePageInputModal .setting-item-control > input { + width: 100%; +} + .quickAddModal .qa-onepage-textarea { width: 100%; height: 120px; @@ -333,10 +345,6 @@ color: var(--text-muted); } -.quickAddModal .qa-onepage-file-picker-setting .setting-item-control { - width: min(28rem, 62%); -} - .quickAddModal .qa-onepage-file-picker { display: flex; flex-direction: column; @@ -428,6 +436,7 @@ .quickAddModal .qa-onepage-slider input[type="range"] { width: 100%; + margin: 0; } .quickAddModal .qa-onepage-slider input[type="number"] { diff --git a/tests/e2e/choice-list-layout.test.ts b/tests/e2e/choice-list-layout.test.ts index 64a7cce12..e14f57382 100644 --- a/tests/e2e/choice-list-layout.test.ts +++ b/tests/e2e/choice-list-layout.test.ts @@ -3,7 +3,7 @@ import { CaptureChoice } from "../../src/types/choices/CaptureChoice"; import { MultiChoice } from "../../src/types/choices/MultiChoice"; import type IChoice from "../../src/types/choices/IChoice"; import { createQuickAddE2EHarness } from "./e2eVault"; -import { waitForElement } from "./uiHelpers"; +import { pressKey, waitForElement } from "./uiHelpers"; const getContext = createQuickAddE2EHarness("choice-list-layout"); @@ -48,3 +48,29 @@ it.each(["is-phone", "is-tablet"])("keeps choice controls compact under %s host await obsidian.dev.evalJson("app.setting.close(); true"); } }); + +it("opens the New choice menu under its button", async () => { + const { obsidian } = getContext(); + try { + await obsidian.dev.evalJson("app.setting.open(); app.setting.openTabById('quickadd'); true"); + await waitForElement(obsidian, ".qaNewChoiceBtn.mod-cta"); + await obsidian.dev.evalJson("document.querySelector('.qaNewChoiceBtn.mod-cta').click(); true"); + await waitForElement(obsidian, ".menu"); + const { button, menu } = await obsidian.dev.evalJson<{ + button: { left: number; right: number; bottom: number }; + menu: { left: number; right: number; top: number }; + }>(`(() => { + const rect = (selector) => { + const { left, right, top, bottom } = document.querySelector(selector).getBoundingClientRect(); + return { left, right, top, bottom }; + }; + return { button: rect('.qaNewChoiceBtn.mod-cta'), menu: rect('.menu') }; + })()`); + expect(menu.top).toBeGreaterThan(button.bottom); + expect(menu.right).toBeCloseTo(button.right, 0); + expect(menu.left).toBeLessThan(button.left); + } finally { + await pressKey(obsidian, "Escape"); + await obsidian.dev.evalJson("app.setting.close(); true"); + } +}); diff --git a/tests/e2e/file-type-label.test.ts b/tests/e2e/file-type-label.test.ts index d5bc4472d..8156a2e20 100644 --- a/tests/e2e/file-type-label.test.ts +++ b/tests/e2e/file-type-label.test.ts @@ -81,6 +81,13 @@ describe("FILE |type: default label", () => { await pressKey(obsidian, "Enter", true); await expectNoPrompt(obsidian); await expect.poll(() => sandbox.read("out/attachment pair.md").catch(() => ""), POLL_OPTS) - .toBe(`image: [[${folder}/photo.png]]\npdf: [[${folder}/scan.pdf]]\n`); + .toMatch(/^image: \[\[[^\]]+\]\]\npdf: \[\[[^\]]+\]\]\n$/); + // Link text follows the vault's link format, so check where each link lands. + const note = JSON.stringify(sandbox.path("out/attachment pair.md")); + await expect.poll(() => obsidian.dev.evalJson<(string | null)[] | null>(`(() => { + const file = app.vault.getAbstractFileByPath(${note}); + return app.metadataCache.getFileCache(file)?.links?.map(({ link }) => + app.metadataCache.getFirstLinkpathDest(link, ${note})?.path ?? null) ?? null; + })()`), POLL_OPTS).toEqual([`${folder}/photo.png`, `${folder}/scan.pdf`]); }); }); diff --git a/tests/e2e/one-page-alignment.test.ts b/tests/e2e/one-page-alignment.test.ts new file mode 100644 index 000000000..9e0c6a1b8 --- /dev/null +++ b/tests/e2e/one-page-alignment.test.ts @@ -0,0 +1,38 @@ +import { expect, it } from "vitest"; +import { createQuickAddE2EHarness } from "./e2eVault"; +import { waitForElement } from "./uiHelpers"; + +const getContext = createQuickAddE2EHarness("one-page-alignment"); + +it("starts every one-page control at the same x regardless of label length", async () => { + const { obsidian } = getContext(); + try { + await obsidian.dev.evalJson(`(() => { + const inputs = []; + for (const type of ['text', 'textarea', 'number', 'slider', 'suggester']) { + for (const label of ['A', 'A considerably longer label for this field']) { + inputs.push({ id: type + label, label, type, options: ['x'], sliderConfig: { min: 0, max: 10 } }); + } + } + void app.plugins.plugins.quickadd.api.requestInputs(inputs).catch(() => undefined); + return true; + })()`); + await waitForElement(obsidian, ".onePageInputModal .qa-onepage-slider"); + const lefts = await obsidian.dev.evalJson>(`(() => Object.fromEntries( + [...[...document.querySelectorAll('.onePageInputModal')].at(-1).querySelectorAll('.setting-item')].map((row, index) => [ + index + ': ' + row.querySelector('.setting-item-name').textContent, + row.querySelector('input, textarea').getBoundingClientRect().left, + ]), + ))()`); + const values = Object.values(lefts); + expect(values).toHaveLength(10); + for (const [row, left] of Object.entries(lefts)) { + expect(left, row).toBeCloseTo(values[0], 0); + } + } finally { + await obsidian.dev.evalJson(`(() => { + [...[...document.querySelectorAll('.onePageInputModal')].at(-1).querySelectorAll('button')].find(e => e.textContent === 'Cancel')?.click(); + return true; + })()`); + } +});