diff --git a/e2e/run.ts b/e2e/run.ts index 38dc5065..a9c172db 100644 --- a/e2e/run.ts +++ b/e2e/run.ts @@ -2245,7 +2245,7 @@ const scenarios: Scenario[] = [ }, { - name: "petname-dice", + name: "petname-dice-and-glyph-random", async run(ctx, origin, browser) { const page = await open(ctx, origin); await visorReady(page); @@ -2257,8 +2257,6 @@ const scenarios: Scenario[] = [ const device = field(/^device petname$/); const userDie = drawer(page).getByRole("button", { name: "Re-roll user petname" }); const deviceDie = drawer(page).getByRole("button", { name: "Re-roll device petname" }); - const userGlyphButton = drawer(page).getByRole("button", { name: "Choose your glyph" }); - const userGlyphDie = drawer(page).getByRole("button", { name: "Re-roll user glyph" }); const glyphValue = (button: Locator) => button.locator(".glyph-tile-face").textContent(); const assertDieGap = async (input: Locator, die: Locator, where: string) => { @@ -2294,47 +2292,57 @@ const scenarios: Scenario[] = [ await die.click(); await waitForInputToDiffer(input, first); } + await saveDraft(page); - eq(await userGlyphDie.getAttribute("aria-disabled"), null, "blank user glyph die was disabled"); - await userGlyphDie.click(); + const userGlyphButton = drawer(page).getByRole("button", { name: "Choose your glyph", exact: true }); + eq(await drawer(page).getByRole("button", { name: "Re-roll user glyph", exact: true }).count(), 0, "settings kept an exterior glyph die"); + await userGlyphButton.click(); + const userPicker = drawer(page).locator(".glyph-picker"); + eq(await userPicker.getByRole("button", { name: "Clear glyph", exact: true }).count(), 0, "user picker kept Clear glyph"); + const userRandom = userPicker.getByRole("button", { name: "Random", exact: true }); + check(await userRandom.isEnabled(), "user Random was disabled for a blank glyph"); + await userRandom.click(); const firstUserGlyph = await glyphValue(userGlyphButton); - check(firstUserGlyph !== null && /^[\u{1f400}-\u{1f43f}]$/u.test(firstUserGlyph), "user glyph roll left the animal range"); - await userGlyphDie.click(); - const secondUserGlyph = await glyphValue(userGlyphButton); - check(secondUserGlyph !== firstUserGlyph, "user glyph repeated its previous roll"); + check(firstUserGlyph !== null && /^[\u{1f400}-\u{1f43f}]$/u.test(firstUserGlyph), "user Random left the animal range"); + check(await userGlyphButton.evaluate((button) => button === document.activeElement), "user Random did not return focus to the tile"); await userGlyphButton.click(); - let glyphPicker = drawer(page).locator(".glyph-picker"); - await glyphPicker.getByRole("searchbox", { name: "Enter glyph or search" }).fill("★"); - await glyphPicker.getByRole("button", { name: "Use ★" }).click(); - eq(await userGlyphDie.getAttribute("aria-disabled"), "true", "manual user glyph remained rerollable"); + check(await userRandom.isEnabled(), "user Random was disabled for an animal glyph"); + await shot(page, "desktop-glyph-random-picker"); + await userRandom.click(); + const secondUserGlyph = await glyphValue(userGlyphButton); + check(secondUserGlyph !== firstUserGlyph, "user Random repeated its selected animal"); + await drawer(page).getByRole("button", { name: "Revert", exact: true }).click(); + eq(await glyphValue(userGlyphButton), "", "Revert kept a random user glyph"); await userGlyphButton.click(); - glyphPicker = drawer(page).locator(".glyph-picker"); - await glyphPicker.getByRole("button", { name: "Clear glyph" }).click(); - eq(await userGlyphDie.getAttribute("aria-disabled"), null, "cleared user glyph die was disabled"); - await userGlyphDie.click(); + await userRandom.click(); const savedUserGlyph = await glyphValue(userGlyphButton); - await shot(page, "desktop-animal-glyph-roll"); await saveDraft(page); await appsButton(page).click(); await paneSettled(page); await openSettingsSheet(page); eq(await drawer(page).getByRole("button", { name: "Re-roll user petname" }).getAttribute("aria-disabled"), null, "saved roll lost eligibility on reopen"); - eq(await drawer(page).getByRole("button", { name: "Re-roll user glyph" }).getAttribute("aria-disabled"), null, "saved glyph roll lost eligibility on reopen"); - eq(await glyphValue(drawer(page).getByRole("button", { name: "Choose your glyph" })), savedUserGlyph, "saved user glyph changed on reopen"); + eq(await glyphValue(drawer(page).getByRole("button", { name: "Choose your glyph", exact: true })), savedUserGlyph, "saved user glyph changed on reopen"); await launchTodoMvc(page); await toAppSheet(page); const app = field(/^petname$/); const appDie = drawer(page).getByRole("button", { name: "Re-roll app petname" }); const appGlyphButton = drawer(page).getByRole("button", { name: "Choose glyph", exact: true }); - const appGlyphDie = drawer(page).getByRole("button", { name: "Re-roll app glyph" }); eq(await appDie.getAttribute("aria-disabled"), "true", "generated app default was rerollable"); - eq(await appGlyphDie.getAttribute("aria-disabled"), null, "blank app glyph die was disabled"); - await appGlyphDie.click(); + eq(await drawer(page).getByRole("button", { name: /app glyph/i }).count(), 0, "app sheet kept an exterior glyph die"); + await appGlyphButton.click(); + const appPicker = drawer(page).locator(".glyph-picker"); + eq(await appPicker.getByRole("button", { name: "Clear glyph", exact: true }).count(), 0, "app picker kept Clear glyph"); + const appRandom = appPicker.getByRole("button", { name: "Random", exact: true }); + check(await appRandom.isEnabled(), "app Random was disabled for a blank glyph"); + await appRandom.click(); const firstAppGlyph = await glyphValue(appGlyphButton); - check(firstAppGlyph !== null && /^[\u{1f400}-\u{1f43f}]$/u.test(firstAppGlyph), "app glyph roll left the animal range"); - await appGlyphDie.click(); - check(await glyphValue(appGlyphButton) !== firstAppGlyph, "app glyph repeated its previous roll"); + check(firstAppGlyph !== null && /^[\u{1f400}-\u{1f43f}]$/u.test(firstAppGlyph), "app Random left the animal range"); + check(await appGlyphButton.evaluate((button) => button === document.activeElement), "app Random did not return focus to the tile"); + await appGlyphButton.click(); + check(await appRandom.isEnabled(), "app Random was disabled for an animal glyph"); + await appRandom.click(); + check(await glyphValue(appGlyphButton) !== firstAppGlyph, "app Random repeated its selected animal"); await app.fill(""); await appDie.click(); await page.waitForFunction(() => { @@ -2342,14 +2350,14 @@ const scenarios: Scenario[] = [ return (label?.querySelector("input") as HTMLInputElement | null)?.value.length; }); await drawer(page).getByRole("button", { name: "Revert", exact: true }).click(); - await appGlyphDie.click(); + await appGlyphButton.click(); + await appRandom.click(); const savedAppGlyph = await glyphValue(appGlyphButton); await saveDraft(page); await openSettingsSheet(page); await paneSettled(page); await toAppSheet(page); eq(await glyphValue(drawer(page).getByRole("button", { name: "Choose glyph", exact: true })), savedAppGlyph, "saved app glyph changed on reopen"); - eq(await drawer(page).getByRole("button", { name: "Re-roll app glyph" }).getAttribute("aria-disabled"), null, "saved app glyph roll lost eligibility on reopen"); await pageCleanDrawer(page); await page.reload(); await visorReady(page); @@ -2357,7 +2365,7 @@ const scenarios: Scenario[] = [ await page.waitForFunction(() => document.querySelector("#visor-drawer") === null); await openSettingsSheet(page); eq(await drawer(page).getByRole("button", { name: "Re-roll user petname" }).getAttribute("aria-disabled"), "true", "reload retained roll eligibility"); - eq(await drawer(page).getByRole("button", { name: "Re-roll user glyph" }).getAttribute("aria-disabled"), "true", "reload retained glyph roll eligibility"); + eq(await glyphValue(drawer(page).getByRole("button", { name: "Choose your glyph", exact: true })), savedUserGlyph, "user glyph did not survive reload"); check(await drawer(page).getByText("word", { exact: true }).count() === 0, "obsolete word field is still rendered"); const reloadedUser = field(/^your petname$/); @@ -2370,7 +2378,6 @@ const scenarios: Scenario[] = [ await toAppSheet(page); eq(await glyphValue(drawer(page).getByRole("button", { name: "Choose glyph", exact: true })), savedAppGlyph, "app glyph did not survive reload"); - eq(await drawer(page).getByRole("button", { name: "Re-roll app glyph" }).getAttribute("aria-disabled"), "true", "reload retained app glyph roll eligibility"); const savedApp = field(/^petname$/); await savedApp.fill(""); await saveDraft(page); @@ -2378,7 +2385,10 @@ const scenarios: Scenario[] = [ await page.setViewportSize({ width: 390, height: 844 }); await assertDieGap(field(/^petname$/), drawer(page).getByRole("button", { name: "Re-roll app petname" }), "mobile app"); - await shot(page, "mobile-animal-glyph-roll"); + const mobileAppGlyph = drawer(page).getByRole("button", { name: "Choose glyph", exact: true }); + await mobileAppGlyph.click(); + await shot(page, "mobile-glyph-random-picker"); + await drawer(page).locator(".glyph-picker").getByRole("searchbox", { name: "Enter glyph or search" }).press("Escape"); const touch = await browser.newContext({ viewport: { width: 390, height: 844 }, hasTouch: true }); try { const mobile = await open(touch, origin); @@ -2390,6 +2400,18 @@ const scenarios: Scenario[] = [ await mobileDie.tap({ force: true }); eq(await mobileUser.inputValue(), before, "disabled touch die changed the draft"); await drawer(mobile).getByRole("tooltip", { name: "clear to re-roll" }).waitFor(); + const mobileGlyph = drawer(mobile).getByRole("button", { name: "Choose your glyph", exact: true }); + const beforeGlyph = await mobileGlyph.textContent(); + await mobileGlyph.tap(); + const mobileDialog = drawer(mobile).locator("dialog.glyph-dialog"); + await mobile.waitForFunction(() => document.querySelector("dialog.glyph-dialog")?.matches(":modal")); + const underlyingApp = await mobile.locator("#visor-app").boundingBox(); + check(underlyingApp !== null, "underlying app button has no touch geometry"); + await mobile.touchscreen.tap(underlyingApp.x + 8, underlyingApp.y + underlyingApp.height / 2); + eq(await mobileDialog.count(), 0, "touch outside kept the glyph modal open"); + eq(await mobileGlyph.textContent(), beforeGlyph, "touch outside changed the draft glyph"); + check(await mobileGlyph.evaluate((button) => button === document.activeElement), "touch outside did not return focus to the glyph tile"); + eq(await drawer(mobile).locator(".pane").getAttribute("aria-label"), "settings", "touch outside activated the underlying self button"); } finally { await touch.close(); } @@ -2947,12 +2969,46 @@ const scenarios: Scenario[] = [ }); await glyph.click(); const picker = drawer(page).locator(".glyph-picker"); + const glyphDialog = drawer(page).locator("dialog.glyph-dialog"); + await page.waitForFunction(() => + document.querySelector("dialog.glyph-dialog")?.matches(":modal") + ); + eq(await drawer(page).locator("dialog:modal").count(), 1, "picker did not open as the only modal dialog"); + eq(await glyphDialog.getAttribute("aria-label"), "Choose your glyph", "glyph dialog has the wrong accessible name"); + const desktopPanel = await picker.boundingBox(); + const desktopViewport = page.viewportSize(); + check(desktopPanel !== null && desktopViewport !== null && desktopPanel.width <= 512 && Math.abs(desktopPanel.x + desktopPanel.width / 2 - desktopViewport.width / 2) <= 1, "desktop glyph modal was not centred at a moderate width"); + eq(await drawer(page).getByRole("button", { name: "Re-roll user glyph", exact: true }).count(), 0, "picker test found an exterior glyph die"); + eq(await picker.getByRole("button", { name: "Clear glyph", exact: true }).count(), 0, "picker test found Clear glyph"); + check(await picker.getByRole("button", { name: "Random", exact: true }).isEnabled(), "Random was not enabled"); const search = picker.getByRole("searchbox", { name: "Enter glyph or search", }); await page.waitForFunction(() => document.activeElement?.getAttribute("type") === "search" ); + await search.press("Shift+Tab"); + eq(await page.locator(":focus").textContent(), "Random", "Shift+Tab escaped the modal instead of reaching Random"); + await page.keyboard.press("Shift+Tab"); + check(await glyphDialog.evaluate((dialog) => dialog === document.activeElement), "backward Tab escaped the modal"); + await page.keyboard.press("Tab"); + eq(await page.locator(":focus").textContent(), "Random", "forward Tab did not wrap inside the modal"); + await search.click(); + await picker.locator("label").click({ position: { x: 4, y: 4 } }); + check(await glyphDialog.evaluate((dialog) => dialog.matches(":modal")), "an inside click dismissed the modal"); + const glyphBeforeDismiss = await glyph.textContent(); + const underlyingApp = await appsButton(page).boundingBox(); + check(underlyingApp !== null, "underlying app button has no geometry"); + await page.mouse.click(underlyingApp.x + 8, underlyingApp.y + underlyingApp.height / 2); + eq(await glyphDialog.count(), 0, "an outside click kept the glyph modal open"); + eq(await glyph.textContent(), glyphBeforeDismiss, "outside dismissal changed the draft glyph"); + check(await glyph.evaluate((button) => button === document.activeElement), "outside dismissal did not return focus to the glyph tile"); + eq(await drawer(page).locator(".pane").getAttribute("aria-label"), "settings", "outside dismissal closed the drawer"); + await glyph.click(); + await picker.getByRole("button", { name: "Close", exact: true }).click(); + eq(await glyph.textContent(), glyphBeforeDismiss, "Close changed the draft glyph"); + check(await glyph.evaluate((button) => button === document.activeElement), "Close did not return focus to the glyph tile"); + await glyph.click(); const actionsBeforeQuery = await drawer(page).locator("#visor-actions") .textContent(); @@ -3089,7 +3145,7 @@ const scenarios: Scenario[] = [ await page.waitForFunction( () => document.querySelector("#visor-circle")?.textContent === "👩🏽‍💻", ); - await page.setViewportSize({ width: 390, height: 780 }); + await page.setViewportSize({ width: 390, height: 400 }); await openSettingsSheet(page); const mobileTile = drawer(page).getByRole("button", { name: "Choose your glyph", @@ -3099,6 +3155,18 @@ const scenarios: Scenario[] = [ const mobileSearch = picker.getByRole("searchbox", { name: "Enter glyph or search", }); + const mobilePanel = await picker.boundingBox(); + check(mobilePanel !== null && mobilePanel.x >= 12 && mobilePanel.x + mobilePanel.width <= 378 && mobilePanel.y >= 12 && mobilePanel.y + mobilePanel.height <= 388, `short mobile glyph modal escaped the viewport gutter: ${JSON.stringify(mobilePanel)}`); + console.log(` short mobile glyph modal bounds: ${JSON.stringify(mobilePanel)}`); + check(await picker.locator(".glyph-results").evaluate((results) => results.scrollHeight > results.clientHeight), "mobile glyph results were not internally scrollable"); + for (const control of [ + picker.getByRole("button", { name: "Random", exact: true }), + mobileSearch, + picker.getByRole("button", { name: "Close", exact: true }), + ]) { + const box = await control.boundingBox(); + check(box !== null && box.y >= 0 && box.y + box.height <= 400, `short mobile modal put a fixed control offscreen: ${JSON.stringify(box)}`); + } await mobileSearch.fill("cat"); await shot(page, "mobile-glyph-picker"); @@ -3119,21 +3187,22 @@ const scenarios: Scenario[] = [ "selecting the saved glyph dirtied the draft", ); - // Clearing is available only inside the picker and remains a draft. + // Random remains available for a manually selected saved glyph and is + // an ordinary draft change that Revert restores. await mobileTile.click(); - await picker.getByRole("button", { name: "Clear glyph", exact: true }) - .click(); - eq( - await mobileTile.textContent(), - "", - "Clear glyph did not clear the draft", - ); + const previousGlyph = await mobileTile.textContent(); + const random = picker.getByRole("button", { name: "Random", exact: true }); + check(await random.isEnabled(), "Random was disabled for a saved manual glyph"); + await random.click(); + const randomGlyph = await mobileTile.textContent(); + check(randomGlyph !== previousGlyph && randomGlyph !== null && /^[\u{1f400}-\u{1f43f}]$/u.test(randomGlyph), "Random did not replace a saved manual glyph with a different animal"); + check(await mobileTile.evaluate((button) => button === document.activeElement), "Random did not return focus to the glyph tile"); await drawer(page).getByRole("button", { name: "Revert", exact: true }) .click(); eq( await mobileTile.textContent(), "👩🏽‍💻", - "Revert did not restore cleared glyph", + "Revert did not restore the saved glyph after Random", ); }, }, diff --git a/visor/src/draft.rs b/visor/src/draft.rs index 8b22552f..1491936d 100644 --- a/visor/src/draft.rs +++ b/visor/src/draft.rs @@ -5,8 +5,6 @@ pub(crate) enum RollTarget { Device, User, App(String), - UserGlyph, - AppGlyph(String), Picker, } diff --git a/visor/src/style.rs b/visor/src/style.rs index 1d03c977..39a36082 100644 --- a/visor/src/style.rs +++ b/visor/src/style.rs @@ -332,22 +332,32 @@ pub(crate) const CSS: &str = r#" border: 0; background: transparent; } #visor-root .glyph-tile-button .glyph-tile-face { pointer-events: none; } +#visor-root .glyph-dialog { + box-sizing: border-box; width: 100vw; max-width: none; height: 100dvh; + max-height: none; margin: 0; padding: max(12px, env(safe-area-inset-top)) 12px + max(12px, env(safe-area-inset-bottom)); + border: 0; background: transparent; color: inherit; + place-items: center; +} +#visor-root .glyph-dialog[open] { display: grid; } +#visor-root .glyph-dialog::backdrop { background: oklch(0 0 0 / 0.45); } #visor-root .glyph-picker { display: flex; flex-direction: column; align-items: stretch; gap: 8px; + box-sizing: border-box; width: min(32rem, 100%); min-height: 0; max-height: 100%; + overflow: hidden; padding: 8px; border: 1px solid var(--edge); border-radius: 6px; background: var(--field); } #visor-root .glyph-picker label { margin: 0; } #visor-root .glyph-results { display: grid; grid-template-columns: repeat(auto-fill, minmax(44px, 1fr)); - gap: 4px; max-height: min(16rem, 36vh); overflow-y: auto; + flex: 1 1 auto; gap: 4px; min-height: 0; max-height: min(24rem, 60dvh); overflow-y: auto; } #visor-root .glyph-results button { padding: 4px; font-size: 24px; } #visor-root .glyph-face { font-family: Apple Color Emoji, Segoe UI Emoji, Noto Color Emoji, "Polyvisor Noto Emoji", emoji, sans-serif; } -#visor-root .glyph-picker .glyph-clear { align-self: flex-start; font-size: inherit; } /* Rows wrap rather than overlap: a name and some framework-voice facts about it do not fit on one 320px line, so the facts follow under the name. */ diff --git a/visor/src/ui.rs b/visor/src/ui.rs index 3229be35..ef8c8781 100644 --- a/visor/src/ui.rs +++ b/visor/src/ui.rs @@ -130,36 +130,6 @@ fn roll_petname( RollTarget::User => set_field(&mut d.user, PETNAME, value), RollTarget::App(_) => set_field(&mut d.app, PETNAME, value), RollTarget::Picker => {} - RollTarget::UserGlyph | RollTarget::AppGlyph(_) => { - unreachable!("petname rolls have only petname targets") - } - } - draft.set(d); -} - -fn roll_glyph( - target: RollTarget, - previous: String, - mut rolls: CopyValue, - mut draft: Signal, - session: Signal>, -) { - if let RollTarget::AppGlyph(expected) = &target - && session.read().as_ref().map(|(_, app)| &app.id) != Some(expected) - { - return; - } - let bytes = crate::component::wasi::random::random::get_random_bytes(4); - let value = roll_animal( - u32::from_le_bytes(bytes.try_into().expect("wasi:random returned four bytes")), - &previous, - ); - rolls.write().activate(target.clone(), value.clone()); - let mut d = draft(); - match target { - RollTarget::UserGlyph => set_field(&mut d.user, GLYPH, value), - RollTarget::AppGlyph(_) => set_field(&mut d.app, GLYPH, value), - _ => unreachable!("glyph rolls have only glyph targets"), } draft.set(d); } @@ -1293,9 +1263,6 @@ pub(crate) fn Visor() -> Element { }; let live = session.read().as_ref().map(|(id, app)| (*id, app.clone())); let live_id = live.as_ref().map(|(id, _)| *id); - let app_glyph_target = live - .as_ref() - .map(|(_, app)| RollTarget::AppGlyph(app.id.clone())); let (info_petname, info_glyph) = { let d = draft.read(); ( @@ -1396,16 +1363,9 @@ pub(crate) fn Visor() -> Element { GlyphPicker { label: "glyph", value: info_glyph, - roll_target: app_glyph_target.clone(), - rolls, - draft, - session, focus_return: current.then(|| focus_glyph.clone()).flatten(), focus_search: current.then(|| focus_glyph_search.clone()).flatten(), onchange: move |value| { - if let Some(target) = app_glyph_target.as_ref() { - rolls.write().invalidate(target); - } let mut d = draft.write(); set_field(&mut d.app, GLYPH, value); }, @@ -2447,10 +2407,6 @@ const GLYPH_PAGE: usize = 96; fn GlyphPicker( label: &'static str, value: String, - roll_target: Option, - rolls: CopyValue, - draft: Signal, - session: Signal>, onchange: EventHandler, focus_return: Option, focus_search: Option, @@ -2493,11 +2449,6 @@ fn GlyphPicker( let more = matches.len() > limit(); matches.truncate(limit()); let empty = matches.is_empty() && direct.is_none(); - let roll_label: &'static str = if matches!(roll_target, Some(RollTarget::UserGlyph)) { - "Re-roll user glyph" - } else { - "Re-roll app glyph" - }; rsx! { div { @@ -2531,86 +2482,100 @@ fn GlyphPicker( }, span { class: "glyph-tile-face", "{value}" } } - if let Some(target) = roll_target.as_ref() { - RollButton { - label: roll_label, - enabled: value.is_empty() || rolls.read().is_active(target, &value), - onclick: { - let target = target.clone(); - let previous = value.clone(); - move |_| { - if previous.is_empty() || rolls.read().is_active(&target, &previous) { - roll_glyph(target.clone(), previous.clone(), rolls, draft, session); - } - } - } - } - } } if open() { - div { class: "glyph-picker", - label { - span { class: "{Voice::Framework.class()}", "Enter glyph or search" } - input { - r#type: "search", - value: "{query}", - "data-visor-focus": focus_search, - oncompositionstart: move |_| composing.set(true), - oncompositionend: move |_| composing.set(false), - oninput: move |e| { - query.set(e.value()); - limit.set(GLYPH_PAGE); - }, - } - } - div { class: "glyph-results", - if let Some(glyph) = direct { - button { - r#type: "button", - title: "Use {glyph}", - aria_label: "Use {glyph}", - onclick: move |_| { - onchange.call(glyph.clone()); + dialog { + class: "glyph-dialog", + aria_label: "Choose {label}", + tabindex: "0", + "data-visor-modal": "", + onclick: move |_| { + open.set(false); + onreturn.call(()); + }, + oncancel: move |e| { + e.prevent_default(); + open.set(false); + onreturn.call(()); + }, + div { + class: "glyph-picker", + onclick: move |e| e.stop_propagation(), + button { + r#type: "button", + onclick: { + let previous = value.clone(); + move |_| { + let bytes = crate::component::wasi::random::random::get_random_bytes(4); + let random = u32::from_le_bytes( + bytes.try_into().expect("wasi:random returned four bytes"), + ); + onchange.call(roll_animal(random, &previous)); open.set(false); onreturn.call(()); + } + }, + "Random" + } + label { + span { class: "{Voice::Framework.class()}", "Enter glyph or search" } + input { + r#type: "search", + value: "{query}", + "data-visor-focus": focus_search, + oncompositionstart: move |_| composing.set(true), + oncompositionend: move |_| composing.set(false), + oninput: move |e| { + query.set(e.value()); + limit.set(GLYPH_PAGE); }, - span { class: "glyph-face", "{glyph}" } } } - for emoji in matches { + div { class: "glyph-results", + if let Some(glyph) = direct { + button { + r#type: "button", + title: "Use {glyph}", + aria_label: "Use {glyph}", + onclick: move |_| { + onchange.call(glyph.clone()); + open.set(false); + onreturn.call(()); + }, + span { class: "glyph-face", "{glyph}" } + } + } + for emoji in matches { + button { + r#type: "button", + title: "{emoji.name()}", + aria_label: "{emoji.name()}", + onclick: move |_| { + onchange.call(emoji.as_str().to_string()); + open.set(false); + onreturn.call(()); + }, + span { class: "glyph-face", "{emoji.as_str()}" } + } + } + } + if more { button { r#type: "button", - title: "{emoji.name()}", - aria_label: "{emoji.name()}", - onclick: move |_| { - onchange.call(emoji.as_str().to_string()); - open.set(false); - onreturn.call(()); - }, - span { class: "glyph-face", "{emoji.as_str()}" } + onclick: move |_| limit += GLYPH_PAGE, + "Show more" } } - } - if more { - button { - r#type: "button", - onclick: move |_| limit += GLYPH_PAGE, - "Show more" + if empty { + span { class: "{Voice::Framework.class()}", "no emoji found" } } - } - if empty { - span { class: "{Voice::Framework.class()}", "no emoji found" } - } - if !value.is_empty() { button { r#type: "button", - class: "glyph-clear", onclick: move |_| { - onchange.call(String::new()); open.set(false); onreturn.call(()); }, - "Clear glyph" + "Close" } } } @@ -2742,14 +2707,9 @@ fn SettingsSheet( GlyphPicker { label: "your glyph", value: user_glyph, - roll_target: Some(RollTarget::UserGlyph), - rolls, - draft, - session, focus_return: focus_glyph, focus_search: focus_glyph_search, onchange: move |value| { - rolls.write().invalidate(&RollTarget::UserGlyph); let mut d = draft.write(); set_field(&mut d.user, GLYPH, value); }, diff --git a/web/focus.ts b/web/focus.ts index b13b8f51..57f7e68e 100644 --- a/web/focus.ts +++ b/web/focus.ts @@ -3,12 +3,14 @@ // The visor is a stream-dom producer and cannot move focus itself: the // pinned receiver's `MountedData` is `()` with `set_focus` unsupported. So // the visor states its intent in markup and this module carries it out. -// Two attributes, both written by visor/src/ui.rs: +// Three attributes, all written by visor/src/ui.rs: // // * `data-visor-focus` on at most one element, valued with a generation // number that advances only when the visor itself caused a transition // worth moving the caret for. Anything else re-renders with the same // number and moves nothing. +// * `data-visor-modal` on a native dialog that must enter the top layer. +// The producer can describe that state but cannot call `showModal()`. // * `data-visor-app-inert` on `#visor-root`, mirrored onto `#app-zone` — // the app zone is the page's element, not the visor's tree, so the // visor cannot mark it itself, and something has to or Tab walks out of @@ -62,6 +64,20 @@ export function attachVisorFocus( root?.hasAttribute("data-visor-app-inert") ?? false, ); + // Modal promotion has to precede the focus request below: focusing a + // dialog descendant before showModal() would leave the browser free to + // move focus again while establishing the modal focus scope. + const picker = visor.querySelector( + "dialog[data-visor-modal]", + ); + if (picker !== null && !picker.open) { + picker.showModal(); + // Chromium performs its own initial-focus step during showModal(). Let + // that finish before serving the visor's more specific search request. + queueMicrotask(settle); + return; + } + const dialog = visor.querySelector("#visor-confirm"); if (dialog !== null && !dialogUp) { dialogUp = true;