Mobile menu editor above boxes with a matching return arrow (#34)

On phones place the existing menu editor above the catalog and add the same return-to-top control used by New Order. Preserve form nodes, unsaved values and active input focus; keep desktop/tablet layout and read-only permissions. Full QA passed, including iPhone WebKit and existing promotion focus regression. Extend real-asset production verification with a backend-blocked mobile-menu scenario.
This commit is contained in:
pavlov346346-source 2026-09-19 20:02:43 +03:00 committed by GitHub
parent ee70c66ed8
commit 5ce7d8d353
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 265 additions and 3 deletions

View File

@ -50,6 +50,7 @@ jobs:
core/catalog-pricing.js
core/client-menu.css
core/ops-ux-v1762.js
core/mobile-order.js
core/banquet-menu.js
core/data-layer-v1773.js
core/trial-demo.js
@ -112,6 +113,9 @@ jobs:
- name: Check the published loading screen, Help, clients and promotions
timeout-minutes: 4
run: node tests/production-ui-smoke.mjs
- name: Check the published mobile menu form and return arrow
timeout-minutes: 4
run: node tests/production-mobile-menu.mjs
- name: Save production UI verification
if: always()
uses: actions/upload-artifact@v4

View File

@ -35,5 +35,79 @@
const observer=new MutationObserver(update);observer.observe(view,{attributes:true,attributeFilter:['class']});observer.observe(document.body,{attributes:true,attributeFilter:['class']});
layout();
}
if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',boot,{once:true});else boot();
// The menu page is created later by ops-ux. Attach once when it exists, then
// observe only its visibility and direct detail children, never the whole app.
function bootMenu(){
const view=document.getElementById('sun-menu-editor-v1762'),layout=view?.querySelector('.sun-menu-layout');
const list=layout?.querySelector(':scope > .sun-menu-list-pane'),detail=document.getElementById('sunMenuDetailV1762');
if(!view||!layout||!list||!detail)return false;
if(view.dataset.ctMobileMenu==='1')return true;
view.dataset.ctMobileMenu='1';
const mobile=matchMedia('(max-width:760px)');
const button=document.createElement('button');button.id='ctMenuTop';button.type='button';button.hidden=true;
button.setAttribute('aria-label','Наверх к редактору меню');button.title='Наверх к редактору меню';
button.setAttribute('aria-controls','sunMenuDetailV1762');
button.innerHTML='<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path d="M12 19V5m-6 6 6-6 6 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg><span>Наверх</span>';
document.body.appendChild(button);
const style=document.createElement('style');style.id='ctMobileMenuStyle';style.textContent=`
#ctMenuTop{display:none}
@media(max-width:760px){
#sun-menu-editor-v1762 .sun-menu-layout{grid-template-columns:minmax(0,1fr)}
#sun-menu-editor-v1762 #sunMenuDetailV1762{grid-column:1;grid-row:1;min-width:0;min-height:0}
#sun-menu-editor-v1762 .sun-menu-list-pane{grid-column:1;grid-row:2;min-width:0;min-height:0}
#sun-menu-editor-v1762 .sun-menu-list{max-height:none;overflow:visible}
#sun-menu-editor-v1762 .sun-menu-detail-placeholder{min-height:76px}
#ctMenuTop:not([hidden]){display:flex;align-items:center;justify-content:center;gap:6px;position:fixed;right:16px;bottom:calc(88px + env(safe-area-inset-bottom,0px));z-index:900;min-width:48px;min-height:48px;padding:10px 14px;border:1px solid #d7bb65;border-radius:24px;background:#f6e7af;color:#302919;box-shadow:0 4px 18px #0002;font:700 13px/1.2 Arial,sans-serif;cursor:pointer}
#ctMenuTop:focus-visible{outline:3px solid #546747;outline-offset:3px}
}
@media print{#ctMenuTop{display:none!important}}
`;document.head.appendChild(style);
function active(){return mobile.matches&&view.classList.contains('on')&&!document.body.classList.contains('sun-cloud-auth-required');}
function visibility(){button.hidden=!active()||document.body.classList.contains('sun-modal-open')||window.scrollY<300;}
function neutralHint(){
const label=detail.querySelector('.sun-menu-detail-placeholder b');
if(label?.textContent==='Выберите позицию слева, чтобы открыть карточку.')label.textContent='Выберите позицию из списка, чтобы открыть карточку.';
}
function arrange(){
// Move the LIST, not the form: an unsaved or focused editor keeps its node,
// input values, file selection and listeners across viewport changes.
if(mobile.matches){if(detail.nextElementSibling!==list)detail.after(list);}
else if(list.nextElementSibling!==detail)layout.insertBefore(list,detail);
neutralHint();visibility();
}
function returnToEditor(){
if(!active())return;
window.scrollTo({top:0,left:0,behavior:'instant'});
detail.setAttribute('tabindex','-1');detail.focus({preventScroll:true});visibility();
}
let frame=0;
function update(){if(frame)return;frame=requestAnimationFrame(()=>{frame=0;neutralHint();visibility();});}
button.addEventListener('click',returnToEditor);
view.addEventListener('click',e=>{
const target=e.target.closest?.('[data-menu-item-v1762],#sunMenuAddV1762');
if(!target||target.disabled||!active())return;
// The existing click handler docks the editor in a zero-delay task. Wait
// for it, and do not scroll if the user has left Menu in the meantime.
setTimeout(()=>requestAnimationFrame(()=>{
// Never take focus or scroll away after the user has started editing.
const focused=document.activeElement;
if(detail.contains(focused)&&focused?.matches('input,textarea,select,[contenteditable="true"]'))return;
if(detail.querySelector(':scope > .dialog,:scope > .sun-menu-readonly'))returnToEditor();
}),0);
});
window.addEventListener('scroll',update,{passive:true});mobile.addEventListener('change',arrange);
const observer=new MutationObserver(update);
observer.observe(view,{attributes:true,attributeFilter:['class']});
observer.observe(document.body,{attributes:true,attributeFilter:['class']});
observer.observe(detail,{childList:true});
arrange();return true;
}
function start(){
boot();
if(!bootMenu()){
const waiting=new MutationObserver(()=>{if(bootMenu())waiting.disconnect();});
waiting.observe(document.body,{childList:true});
}
}
if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',start,{once:true});else start();
})();

View File

@ -0,0 +1,84 @@
import assert from 'node:assert/strict';
import {expect} from '@playwright/test';
// The real application assets run with a synthetic local workspace. No backend
// traffic or real customer credentials are allowed, including on production.
export async function bootMenuFixture(page,baseUrl='http://127.0.0.1:4173/'){
const base=new URL(baseUrl);
await page.route('**/*',route=>{
const req=route.request(),url=new URL(req.url());
if(req.method()==='GET'&&url.origin===base.origin&&!url.pathname.startsWith('/api/'))return route.continue();
return route.abort();
});
await page.addInitScript(()=>{
const id='mobile-menu-workspace',user={id:'mobile-menu-user',email:'menu-test@example.invalid',email_confirmed_at:'2026-01-01T00:00:00Z'};
localStorage.setItem('sunCloudV2Config',JSON.stringify({workspaceId:id,localWorkspaceId:id,tenantStorageReady:true,autoSync:false}));
localStorage.setItem('sunBoxes',JSON.stringify(Array.from({length:24},(_,i)=>({id:`mobile-box-${i+1}`,name:`Бокс ${String(i+1).padStart(2,'0')}`,category:0,price:1000+i*10,weight:'600 г',pieces:12,ingredients:[['Томаты',1,'кг']]}))));
localStorage.setItem('sunOrders',JSON.stringify([{id:1,event:'Тестовый заказ',date:'2099-12-20',time:'12:00',status:'Новый',lines:[{id:'mobile-box-1',qty:2,price:1000}],total:2000}]));
const features=Object.fromEntries('orders calendar clients catalog_view catalog_edit production shopping stock routes mailings money stats_basic stats_advanced team suppliers print settings branding client_offers offer_templates backups audit users_manage'.split(' ').map(k=>[k,true]));
window.supabase={createClient:()=>({
auth:{onAuthStateChange:()=>({data:{subscription:{unsubscribe(){}}}}),getSession:async()=>({data:{session:{user}},error:null}),getUser:async()=>({data:{user},error:null})},
rpc:async name=>({data:name==='sun_my_workspaces'?[{id,name:'Тестовая компания',role:'admin',is_active:true,permissions:{}}]:name==='sun_subscription_snapshot'?{plan_id:'full',plan_name:'Полный',status:'active',access_mode:'full',features}:name==='sun_is_platform_admin'?false:name==='caterium_trial_demo_status'?{canInstall:false,canUpgrade:false}:null,error:null}),
channel:()=>({on(){return this},subscribe(){return this}}),removeChannel(){}
})};
});
await page.goto(base.href,{waitUntil:'domcontentloaded',timeout:60000});
await expect(page.locator('body > header')).toBeVisible({timeout:20000});
await page.waitForFunction(()=>window.SunOpsUXV1762&&window.CateriumDataV1773&&window.__cateriumOrderEnhancementsV1775);
await page.locator('header nav').getByRole('button',{name:'Меню',exact:true}).click();
await expect(page.locator('#sun-menu-editor-v1762')).toHaveAttribute('data-ct-mobile-menu','1');
await expect(page.locator('[data-menu-item-v1762]')).toHaveCount(24);
}
export async function menuGeometry(page){
let geometry;
// Read rectangles and media queries atomically. WebKit/mobile Chromium can
// update the layout viewport a frame after a resize or keyboard transition.
await expect.poll(async()=>{
geometry=await page.evaluate(()=>{
const d=document.getElementById('sunMenuDetailV1762'),l=document.querySelector('.sun-menu-list-pane');
return {detail:d.getBoundingClientRect().toJSON(),list:l.getBoundingClientRect().toJSON(),mobile:matchMedia('(max-width:760px)').matches,singleColumn:matchMedia('(max-width:950px)').matches,formFirst:d.nextElementSibling===l,listFirst:l.nextElementSibling===d,viewport:innerWidth,scrollWidth:document.documentElement.scrollWidth};
});
const {detail:d,list:l,mobile,singleColumn,formFirst,listFirst}=geometry;
return mobile?(formFirst&&d.bottom<=l.y+1):(listFirst&&(singleColumn?l.bottom<=d.y+1:l.right<=d.x+1));
},{message:'Menu layout and reading order must match the current breakpoint'}).toBe(true);
if(geometry.mobile)assert(geometry.scrollWidth<=geometry.viewport+1,'No horizontal overflow on phones');
return geometry;
}
export async function exerciseMenuNavigation(page,{screenshot=async()=>{}}={}){
const geometry=await menuGeometry(page),top=page.locator('#ctMenuTop');
await expect(top).toBeHidden();
const before=await page.evaluate(()=>({orders:localStorage.sunOrders,boxes:localStorage.sunBoxes}));
await page.locator('[data-menu-item-v1762="mobile-box-24"]').click();
await expect(page.locator('#sunMenuDetailV1762 > .dialog #boxName')).toHaveValue('Бокс 24');
if(geometry.mobile)await expect.poll(()=>page.evaluate(()=>window.scrollY)).toBe(0);
await menuGeometry(page);
await screenshot('menu-form-top');
await page.locator('#boxName').fill('Несохранённый бокс');
await page.locator('#boxPrice').fill('1350');
await page.evaluate(()=>{window.mobileEditorNode=document.getElementById('boxName');window.mobileEditorInputEvents=0;mobileEditorNode.addEventListener('input',()=>window.mobileEditorInputEvents++);});
await page.locator('[data-menu-item-v1762="mobile-box-24"]').scrollIntoViewIfNeeded();
if(geometry.mobile){
await expect(top).toBeVisible();
await expect(page.locator('#ctOrderTop')).toBeHidden();
const bounds=await top.boundingBox();assert(bounds.width>=48&&bounds.height>=48);
await screenshot('menu-arrow');
await top.click();
await expect.poll(()=>page.evaluate(()=>window.scrollY)).toBe(0);
await expect(page.locator('#sunMenuDetailV1762')).toBeFocused();
await expect(top).toBeHidden();
}else await expect(top).toBeHidden();
await expect(page.locator('#boxName')).toHaveValue('Несохранённый бокс');
await expect(page.locator('#boxPrice')).toHaveValue('1350');
assert(await page.evaluate(()=>window.mobileEditorNode===document.getElementById('boxName')),'The form must not be recreated');
assert.deepEqual(await page.evaluate(()=>({orders:localStorage.sunOrders,boxes:localStorage.sunBoxes})),before,'Navigation must not save or change data');
await page.locator('#saveBoxButton').click();
await expect.poll(()=>page.evaluate(()=>JSON.parse(localStorage.sunBoxes).find(x=>x.id==='mobile-box-24')?.name)).toBe('Несохранённый бокс');
assert.equal(await page.evaluate(()=>JSON.parse(localStorage.sunBoxes).find(x=>x.id==='mobile-box-24').price),1350);
assert.equal(await page.evaluate(()=>localStorage.sunOrders),before.orders);
await page.locator('header nav').getByRole('button',{name:'Заказы',exact:true}).click();
await expect(top).toBeHidden();
await expect(page.locator('#editor > .dialog')).toHaveCount(1);
return {...geometry,navigationPreservesDraft:true,saveWorks:true,ordersUnchanged:true};
}

View File

@ -0,0 +1,81 @@
import {test,expect} from '@playwright/test';
import {bootMenuFixture,menuGeometry,exerciseMenuNavigation} from './mobile-menu-scenario.mjs';
test.use({serviceWorkers:'block'});
test('mobile menu puts editing above boxes and returns without losing unsaved values',async({page})=>{
await bootMenuFixture(page);await exerciseMenuNavigation(page);
});
test('mobile menu viewport changes move the list, not the focused editor',async({page})=>{
await bootMenuFixture(page);
await page.locator('[data-menu-item-v1762="mobile-box-1"]').click();
await expect(page.locator('#sunMenuDetailV1762 > .dialog')).toBeVisible();
// The automatic return is phone-only; desktop keeps its existing scroll.
if(await page.evaluate(()=>matchMedia('(max-width:760px)').matches))await expect.poll(()=>page.evaluate(()=>window.scrollY)).toBe(0);
await page.waitForTimeout(80);
await page.locator('#boxName').fill('Сохрани ввод при повороте');
await page.locator('#boxName').focus();
await page.evaluate(()=>{window.formInput=document.getElementById('boxName');window.inputsSeen=0;formInput.addEventListener('input',()=>window.inputsSeen++);});
for(const width of [1440,390,760,1440,390]){
await page.setViewportSize({width,height:900});
await expect.poll(()=>page.evaluate(()=>{const d=document.getElementById('sunMenuDetailV1762'),list=document.querySelector('.sun-menu-list-pane');return matchMedia('(max-width:760px)').matches?d.nextElementSibling===list:list.nextElementSibling===d;})).toBe(true);
await menuGeometry(page);
await expect(page.locator('#boxName')).toHaveValue('Сохрани ввод при повороте');
await expect(page.locator('#boxName')).toBeFocused();
expect(await page.evaluate(()=>formInput===document.getElementById('boxName'))).toBe(true);
}
await page.locator('#boxName').fill('Обработчик остался');
expect(await page.evaluate(()=>inputsSeen)).toBeGreaterThan(0);
await expect(page.locator('#ctMenuTop')).toHaveCount(1);
});
test('mobile menu search, category and new item keep working; leaving prevents a delayed scroll',async({page})=>{
await bootMenuFixture(page);
await page.locator('#sunMenuSearchV1762').fill('Бокс 07');
await expect(page.locator('[data-menu-item-v1762]')).toHaveCount(1);
await page.locator('[data-menu-cat-v1762="5"]').click();
await page.locator('#sunMenuAddV1762').click();
await expect(page.locator('#sunMenuDetailV1762 > .dialog')).toBeVisible();
await expect(page.locator('#itemCategory')).toHaveValue('5');
if(await page.evaluate(()=>matchMedia('(max-width:760px)').matches))await expect.poll(()=>page.evaluate(()=>window.scrollY)).toBe(0);
await menuGeometry(page);
await page.locator('#boxName').fill('Премиум с телефона');await page.locator('#boxPrice').fill('2200');await page.locator('#saveBoxButton').click();
await expect.poll(()=>page.evaluate(()=>JSON.parse(localStorage.sunBoxes).find(x=>x.name==='Премиум с телефона')?.category)).toBe(5);
await page.evaluate(()=>{document.getElementById('sunMenuAddV1762').click();[...document.querySelectorAll('header nav button')].find(b=>b.textContent.trim()==='Заказы').click();});
await page.waitForTimeout(100);
await expect(page.locator('#editor > .dialog')).toHaveCount(1);
await expect(page.locator('#ctMenuTop')).toBeHidden();
await page.locator('header nav').getByRole('button',{name:'Меню',exact:true}).click();
await expect(page.locator('#ctMenuTop')).toHaveCount(1);
});
test('read-only mobile menu opens the card above the list without enabling editing',async({page})=>{
await bootMenuFixture(page);
const before=await page.evaluate(()=>localStorage.sunBoxes);
await page.evaluate(()=>{SunCloudV2.hasPermission=p=>p==='catalog.view'||p==='app.read';window.dispatchEvent(new Event('sun:cloud-permissions-changed'));});
await expect(page.locator('#sunMenuAddV1762')).toBeDisabled();
await page.locator('[data-menu-item-v1762="mobile-box-24"]').click();
await expect(page.locator('#sunMenuDetailV1762 .sun-menu-readonly')).toContainText('Бокс 24');
if(await page.evaluate(()=>matchMedia('(max-width:760px)').matches))await expect.poll(()=>page.evaluate(()=>window.scrollY)).toBe(0);
await menuGeometry(page);
await expect(page.locator('#sunMenuDetailV1762 > .dialog')).toHaveCount(0);
expect(await page.evaluate(()=>localStorage.sunBoxes)).toBe(before);
});
test('a queued mobile return does not steal focus once the user starts typing',async({page})=>{
await bootMenuFixture(page);
await page.evaluate(()=>{
document.querySelector('[data-menu-item-v1762="mobile-box-1"]').click();
// Docking is queued by the existing menu handler. Start typing in the next
// task, before the mobile requestAnimationFrame navigation callback runs.
setTimeout(()=>{
const input=document.getElementById('boxName');input.value='Уже редактирую';input.focus({preventScroll:true});
},0);
});
await expect(page.locator('#sunMenuDetailV1762 > .dialog #boxName')).toHaveValue('Уже редактирую');
await page.waitForTimeout(150);
await expect(page.locator('#boxName')).toBeFocused();
await expect(page.locator('#boxName')).toHaveValue('Уже редактирую');
});

View File

@ -2,13 +2,13 @@ import { defineConfig, devices } from '@playwright/test';
import {fileURLToPath} from 'node:url';
export default defineConfig({
testDir:'.',
testMatch:['client-menu.spec.mjs','ui-stability.spec.mjs','help-center.spec.mjs','app.spec.mjs','theme-startup.spec.mjs','company-branding.spec.mjs','order-import.spec.mjs','account-access.spec.mjs','banquet-menu.spec.mjs','calendar-print.spec.mjs','login-recovery.spec.mjs','workspace-loading.spec.mjs','trial-demo.spec.mjs','proposal-quality.spec.mjs'],
testMatch:['mobile-menu.spec.mjs','client-menu.spec.mjs','ui-stability.spec.mjs','help-center.spec.mjs','app.spec.mjs','theme-startup.spec.mjs','company-branding.spec.mjs','order-import.spec.mjs','account-access.spec.mjs','banquet-menu.spec.mjs','calendar-print.spec.mjs','login-recovery.spec.mjs','workspace-loading.spec.mjs','trial-demo.spec.mjs','proposal-quality.spec.mjs'],
timeout:30000,
use:{baseURL:'http://127.0.0.1:4173'},
webServer:{command:'npx http-server public -p 4173 -c-1',cwd:fileURLToPath(new URL('../',import.meta.url)),port:4173,reuseExistingServer:true},
projects:[
{name:'iphone-pdf',testMatch:['proposal-quality.spec.mjs'],grep:/all six selections|transparent wide|an actual offer downloads/,use:{...devices['iPhone 13'],serviceWorkers:'block'}},
{name:'iphone-webkit',testMatch:['client-menu.spec.mjs','ui-stability.spec.mjs','help-center.spec.mjs','login-recovery.spec.mjs','workspace-loading.spec.mjs','account-access.spec.mjs','calendar-print.spec.mjs'],use:{...devices['iPhone 13'],serviceWorkers:'block'}},
{name:'iphone-webkit',testMatch:['mobile-menu.spec.mjs','client-menu.spec.mjs','ui-stability.spec.mjs','help-center.spec.mjs','login-recovery.spec.mjs','workspace-loading.spec.mjs','account-access.spec.mjs','calendar-print.spec.mjs'],use:{...devices['iPhone 13'],serviceWorkers:'block'}},
{name:'desktop',use:{...devices['Desktop Chrome']}},
{name:'mobile-390',use:{viewport:{width:390,height:844},isMobile:true,hasTouch:true}}
]

View File

@ -0,0 +1,19 @@
import fs from 'node:fs/promises';
import {chromium} from '@playwright/test';
import {bootMenuFixture,exerciseMenuNavigation} from './mobile-menu-scenario.mjs';
const base=new URL(process.env.TIMEWEB_BASE_URL||'https://app.caterium.ru');
if(base.protocol!=='https:')throw new Error('Production UI verification requires HTTPS');
const output='production-ui-results';await fs.mkdir(output,{recursive:true});
const browser=await chromium.launch(),results=[];
try{
for(const width of [390,1440]){
const context=await browser.newContext({viewport:{width,height:900},serviceWorkers:'block'});
try{
const page=await context.newPage();
await bootMenuFixture(page,base.href);
const result=await exerciseMenuNavigation(page,{screenshot:async name=>{await page.screenshot({path:`${output}/${name}-${width}.png`,animations:'disabled'});}});
results.push({width,...result});console.log(`PASS published menu ${width}px: form placement, arrow, unsaved values, save, unchanged orders`);
}finally{await context.close();}
}
await fs.writeFile(`${output}/mobile-menu-result.json`,JSON.stringify({base:base.href,checkedAt:new Date().toISOString(),backend:'synthetic, network-blocked',results},null,2));
}finally{await browser.close();}