// @requirements REQ-PRODUCT-STORAGE-EXCEPTIONS // @requirements REQ-UK-EXCEPTIONS REQ-PRODUCT-MOBILE REQ-PRODUCT-A11Y import type {StorageExceptionController,StorageExceptionSnapshot} from './storage-exceptions.js'; import type {ConsentStateController} from './consent-types.js'; /** A deliberately broad rejection must close BOTH gates. Accepting cookie consent never resumes an objection. */ export async function rejectAllOptionalActivity(consent:ConsentStateController,exceptions:StorageExceptionController):Promise<{consentPersisted:boolean}>{ if(consent.getConfig().site.id!==exceptions.siteId||exceptions.getPolicy().entries.some(e=>e.serviceIds.some(id=>consent.getConfig().consent?.services.some(s=>s.id===id))))throw Error('Use the same site and disjoint consent/exception service scopes.'); let stopping:Promise;try{stopping=exceptions.objectAll();}catch(error){stopping=Promise.reject(error);}let result;try{result=consent.rejectAll();}catch(error){await stopping.catch(()=>{});throw error;}await stopping;return {consentPersisted:result.persisted}; } export interface StorageExceptionControlOptions {idPrefix:string;headingLevel?:1|2|3|4;title?:string;description?:string} /** Inline information and direct switches. No automatic dialog, consent claim, remote asset or page overlay. */ export function mountStorageExceptionControls(container:HTMLElement,controller:StorageExceptionController,options:StorageExceptionControlOptions):{destroy():void}{ if(!container||!container.isConnected||typeof container.append!=='function'||!options||!/^[a-z][a-z0-9-]{0,79}$/u.test(options.idPrefix)||options.headingLevel!==undefined&&![1,2,3,4].includes(options.headingLevel)||[options.title,options.description].some(v=>v!==undefined&&(typeof v!=='string'||v.length>2000)))throw Error('Use a container, unique control ID and bounded wording.'); if(!/^en(?:-|$)/iu.test(controller.getConfig().site.locale))throw Error('Storage exception controls do not yet support localized UI. Use the headless controller.'); const policy=controller.getPolicy(),doc=container.ownerDocument,root=doc.createElement('section'),heading=doc.createElement('h'+(options.headingLevel??2)),description=doc.createElement('p'),list=doc.createElement('div'),notice=doc.createElement('p'),link=doc.createElement('a'),status=doc.createElement('p'),retry=doc.createElement('button'),off=doc.createElement('button'),rows=new Map(); root.className='nb-exception-controls';heading.id=options.idPrefix+'-heading';heading.textContent=options.title??'Storage and access settings';root.setAttribute('aria-labelledby',heading.id);description.textContent=options.description??'You can switch these features off at any time. These settings are separate from services that ask for consent.';list.className='nb-exception-grid';notice.className='nb-exception-memory';notice.textContent=policy.preferenceNotice;link.href=policy.information.href;link.textContent=policy.information.label;status.className='nb-exception-status';status.setAttribute('role','status');retry.type=off.type='button';retry.textContent='Try again';off.textContent='Turn off these features';off.className='nb-exception-off';retry.hidden=true; let closed=false,actionMessage='',last:StorageExceptionSnapshot=controller.getSnapshot(); // A focused native control stays focusable while pending. aria-disabled describes // that state; its click/change handlers block repeat actions until persistence ends. const locked=(control:HTMLElement)=>control.getAttribute('aria-disabled')==='true'; const unavailable=(control:HTMLInputElement|HTMLButtonElement,value:boolean)=>{control.disabled=value&&doc.activeElement!==control;control.setAttribute('aria-disabled',String(value));}; const finishFocus=(control:HTMLInputElement|HTMLButtonElement)=>{control.addEventListener('blur',()=>{if(locked(control))control.disabled=true;if(control===retry&&last.persistence!=='unavailable')retry.hidden=true;});}; finishFocus(off);finishFocus(retry); const act=(work:()=>Promise)=>{actionMessage='';try{void work().catch(()=>{actionMessage='The change could not be confirmed. Check the saved settings before trying again.';render(controller.getSnapshot());});}catch{actionMessage='The settings are unavailable.';render(controller.getSnapshot());}}; for(const e of policy.entries){const row=doc.createElement('label'),copy=doc.createElement('span'),title=doc.createElement('strong'),detail=doc.createElement('span'),state=doc.createElement('span'),input=doc.createElement('input');row.className='nb-exception-row';input.type='checkbox';input.id=options.idPrefix+'-'+e.id;row.htmlFor=input.id;title.textContent=e.title;detail.textContent=e.description;detail.id=input.id+'-description';state.className='nb-exception-entry-status';copy.append(title,detail,state);input.setAttribute('aria-describedby',detail.id);row.append(copy,input);list.append(row);rows.set(e.id,{input,status:state});finishFocus(input);input.addEventListener('click',event=>{if(locked(input))event.preventDefault();});input.addEventListener('change',()=>{if(locked(input)){render(last);return;}const next=input.checked;act(()=>next?controller.resume(e.id,{expectedRevision:last.revision!}):controller.object(e.id));});} function render(s:StorageExceptionSnapshot){if(closed)return;last=s;for(const [id,row] of rows){const entry=s.entries[id]!;row.input.checked=entry.availability==='available';unavailable(row.input,s.destroyed||entry.pending||s.persistence!=='available'||['scope-restricted','stale-definition'].includes(entry.reason));row.status.textContent=entry.reason==='available'?(policy.entries.find(e=>e.id===id)!.serviceIds.some(id=>s.services[id]==='reload-required')?'Enabled · reload needed':'On'):entry.reason==='objection'?'Off · your choice':entry.reason==='stale-definition'?'Off · reload for updated information':entry.reason==='scope-restricted'?'Off · this use needs another review':entry.pending?'Saving your choice…':s.persistence==='loading'?'Checking saved settings…':'Off';} status.textContent=actionMessage||(s.persistence==='unavailable'?'These features are off because your saved settings could not be confirmed.':s.persistence==='loading'?'Checking the settings saved in this browser.':'Changes apply to this browser. Switching a feature off does not recall information already sent.');retry.hidden=s.persistence!=='unavailable'&&doc.activeElement!==retry;unavailable(retry,s.destroyed||s.persistence!=='unavailable');unavailable(off,s.destroyed||Object.values(s.entries).every(e=>e.objection==='objected')||Object.values(s.entries).some(e=>e.pending)); } retry.addEventListener('click',()=>{if(locked(retry))return;actionMessage='';act(()=>controller.refresh());});off.addEventListener('click',()=>{if(!locked(off))act(()=>controller.objectAll());});const actions=doc.createElement('div');actions.className='nb-exception-actions';actions.append(off,retry);root.append(heading,description,list,actions,status,notice,link);container.append(root);const unsubscribe=controller.subscribe(render),detach=controller.attachPresentation();const observer=doc.defaultView?.MutationObserver?new doc.defaultView.MutationObserver(()=>{if(!root.isConnected)destroy();}):undefined;observer?.observe(doc.documentElement,{childList:true,subtree:true});function destroy(){if(closed)return;closed=true;observer?.disconnect();detach();unsubscribe();root.remove();}return {destroy}; }