fix: j/k shortcuts correctly set active element (#1028)

fixes #1018
This commit is contained in:
Nolan Lawson 2019-02-21 23:50:27 -08:00 committed by GitHub
parent 5d703e9612
commit 42e466f3c2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 79 additions and 68 deletions

View File

@ -5,13 +5,5 @@
virtualIndex={index} virtualIndex={index}
virtualLength={length} virtualLength={length}
virtualKey={key} virtualKey={key}
active={active}
/> />
</div> </div>
<script>
export default {
computed: {
'active': ({ $activeItem, key }) => $activeItem === key
}
}
</script>

View File

@ -8,7 +8,6 @@ class ListStore extends RealmStore {
const listStore = new ListStore() const listStore = new ListStore()
listStore.computeForRealm('activeItem', null)
listStore.computeForRealm('intersectionStates', {}) listStore.computeForRealm('intersectionStates', {})
if (process.browser && process.env.NODE_ENV !== 'production') { if (process.browser && process.env.NODE_ENV !== 'production') {

View File

@ -15,8 +15,10 @@
export default { export default {
data: () => ({ data: () => ({
scope: 'global', scope: 'global',
itemToKey: (item) => item, itemToKey: (item) => item.key,
keyToElement: (key) => document.getElementById('list-item-' + key), keyToElement: (key) => {
return document.querySelector(`[shortcut-key=${JSON.stringify(key)}]`)
},
activeItemChangeTime: 0 activeItemChangeTime: 0
}), }),
computed: { computed: {
@ -94,7 +96,11 @@
} }
}, },
checkActiveItem (timeStamp) { checkActiveItem (timeStamp) {
let { activeItem } = this.store.get() let activeElement = document.activeElement
if (!activeElement) {
return null
}
let activeItem = activeElement.getAttribute('shortcut-key')
if (!activeItem) { if (!activeItem) {
return null return null
} }
@ -108,7 +114,14 @@
}, },
setActiveItem (key, timeStamp) { setActiveItem (key, timeStamp) {
this.set({ activeItemChangeTime: timeStamp }) this.set({ activeItemChangeTime: timeStamp })
this.store.setForRealm({ activeItem: key }) let { keyToElement } = this.get()
try {
keyToElement(key).focus({
preventScroll: true
})
} catch (err) {
console.error(err)
}
} }
} }
} }

View File

@ -1,6 +1,6 @@
{#if status} {#if status}
<Status {index} {length} {timelineType} {timelineValue} {focusSelector} <Status {index} {length} {timelineType} {timelineValue} {focusSelector}
{status} {notification} {active} {shortcutScope} on:recalculateHeight {status} {notification} {shortcutScope} on:recalculateHeight
/> />
{:else} {:else}
<article class={className} <article class={className}
@ -8,7 +8,8 @@
aria-posinset={index} aria-posinset={index}
aria-setsize={length} aria-setsize={length}
aria-label={ariaLabel} aria-label={ariaLabel}
status-active={active} focus-key={focusKey}
shortcut-key={shortcutScope}
> >
<StatusHeader {notification} {notificationId} {status} {statusId} {timelineType} <StatusHeader {notification} {notificationId} {status} {statusId} {timelineType}
{account} {accountId} {uuid} isStatusInNotification="true" /> {account} {accountId} {uuid} isStatusInNotification="true" />
@ -25,8 +26,9 @@
padding: 10px 20px; padding: 10px 20px;
border-bottom: 1px solid var(--main-border); border-bottom: 1px solid var(--main-border);
} }
.notification-article.status-active { .notification-article:focus {
background-color: var(--status-active-background); background-color: var(--status-active-background);
outline: none;
} }
@media (max-width: 767px) { @media (max-width: 767px) {
.notification-article { .notification-article {
@ -53,7 +55,6 @@
Shortcut Shortcut
}, },
data: () => ({ data: () => ({
active: false,
shortcutScope: null shortcutScope: null
}), }),
store: () => store, store: () => store,
@ -69,11 +70,11 @@
ariaLabel: ({ status, account, $omitEmojiInDisplayNames }) => ( ariaLabel: ({ status, account, $omitEmojiInDisplayNames }) => (
!status && `${getAccountAccessibleName(account, $omitEmojiInDisplayNames)} followed you, @${account.acct}` !status && `${getAccountAccessibleName(account, $omitEmojiInDisplayNames)} followed you, @${account.acct}`
), ),
className: ({ active, $underlineLinks }) => (classname( className: ({ $underlineLinks }) => (classname(
'notification-article', 'notification-article',
active && 'status-active',
$underlineLinks && 'underline-links' $underlineLinks && 'underline-links'
)) )),
focusKey: ({ uuid }) => `notification-follower-${uuid}`
}, },
methods: { methods: {
openAuthorProfile () { openAuthorProfile () {

View File

@ -2,6 +2,7 @@
tabindex="0" tabindex="0"
delegate-key={delegateKey} delegate-key={delegateKey}
focus-key={delegateKey} focus-key={delegateKey}
shortcut-key={shortcutScope}
aria-posinset={index} aria-posinset={index}
aria-setsize={length} aria-setsize={length}
aria-label={ariaLabel} aria-label={ariaLabel}
@ -74,8 +75,9 @@
background-color: var(--status-direct-background); background-color: var(--status-direct-background);
} }
.status-article.status-active { .status-article:focus {
background-color: var(--status-active-background); background-color: var(--status-active-background);
outline: none;
} }
.status-article.status-in-own-thread { .status-article.status-in-own-thread {
@ -171,7 +173,6 @@
Shortcut Shortcut
}, },
data: () => ({ data: () => ({
active: false,
notification: void 0, notification: void 0,
replyVisibility: void 0, replyVisibility: void 0,
contentPreloaded: false, contentPreloaded: false,
@ -272,12 +273,11 @@
status.reblog || status.reblog ||
timelineType === 'pinned' timelineType === 'pinned'
), ),
className: ({ visibility, timelineType, isStatusInOwnThread, active, $underlineLinks, $disableTapOnStatus }) => (classname( className: ({ visibility, timelineType, isStatusInOwnThread, $underlineLinks, $disableTapOnStatus }) => (classname(
'status-article', 'status-article',
visibility === 'direct' && 'status-direct', visibility === 'direct' && 'status-direct',
timelineType !== 'search' && 'status-in-timeline', timelineType !== 'search' && 'status-in-timeline',
isStatusInOwnThread && 'status-in-own-thread', isStatusInOwnThread && 'status-in-own-thread',
active && 'status-active',
$underlineLinks && 'underline-links', $underlineLinks && 'underline-links',
!$disableTapOnStatus && 'tap-on-status' !$disableTapOnStatus && 'tap-on-status'
)), )),

View File

@ -6,7 +6,6 @@
shortcutScope={virtualKey} shortcutScope={virtualKey}
index={virtualIndex} index={virtualIndex}
length={virtualLength} length={virtualLength}
{active}
on:recalculateHeight /> on:recalculateHeight />
<script> <script>
import Notification from '../status/Notification.html' import Notification from '../status/Notification.html'

View File

@ -5,7 +5,6 @@
shortcutScope={virtualKey} shortcutScope={virtualKey}
index={virtualIndex} index={virtualIndex}
length={virtualLength} length={virtualLength}
active={active}
on:recalculateHeight /> on:recalculateHeight />
<script> <script>
import Status from '../status/Status.html' import Status from '../status/Status.html'

View File

@ -18,7 +18,7 @@
{/if} {/if}
</div> </div>
</VirtualListContainer> </VirtualListContainer>
<ScrollListShortcuts bind:items=$visibleItems <ScrollListShortcuts items={$visibleItems}
itemToKey={(visibleItem) => visibleItem.key} /> itemToKey={(visibleItem) => visibleItem.key} />
<style> <style>
.virtual-list { .virtual-list {
@ -122,7 +122,9 @@
return return
} }
mark('calculateListOffset') mark('calculateListOffset')
let listOffset = node.offsetParent.offsetTop let { offsetParent } = node
// TODO: offsetParent is null sometimes in testcafe tests
let listOffset = offsetParent ? offsetParent.offsetTop : 0
this.store.setForRealm({ listOffset }) this.store.setForRealm({ listOffset })
stop('calculateListOffset') stop('calculateListOffset')
} }

View File

@ -1,5 +1,4 @@
<div class="virtual-list-item list-item {shown ? 'shown' : ''}" <div class="virtual-list-item list-item {shown ? 'shown' : ''}"
id={`list-item-${key}`}
aria-hidden={!shown} aria-hidden={!shown}
ref:node ref:node
style="transform: translateY({offset}px);" > style="transform: translateY({offset}px);" >
@ -8,7 +7,6 @@
virtualIndex={index} virtualIndex={index}
virtualLength={$length} virtualLength={$length}
virtualKey={key} virtualKey={key}
active={active}
on:recalculateHeight="doRecalculateHeight()"/> on:recalculateHeight="doRecalculateHeight()"/>
</div> </div>
<style> <style>
@ -52,8 +50,7 @@
}, },
store: () => virtualListStore, store: () => virtualListStore,
computed: { computed: {
'shown': ({ $itemHeights, key }) => $itemHeights[key] > 0, 'shown': ({ $itemHeights, key }) => $itemHeights[key] > 0
'active': ({ $activeItem, key }) => $activeItem === key
}, },
methods: { methods: {
doRecalculateHeight () { doRecalculateHeight () {

View File

@ -22,7 +22,6 @@ virtualListStore.computeForRealm('scrollHeight', 0)
virtualListStore.computeForRealm('offsetHeight', 0) virtualListStore.computeForRealm('offsetHeight', 0)
virtualListStore.computeForRealm('listOffset', 0) virtualListStore.computeForRealm('listOffset', 0)
virtualListStore.computeForRealm('itemHeights', {}) virtualListStore.computeForRealm('itemHeights', {})
virtualListStore.computeForRealm('activeItem', null)
virtualListStore.compute('rawVisibleItems', virtualListStore.compute('rawVisibleItems',
['items', 'scrollTop', 'itemHeights', 'offsetHeight', 'showHeader', 'headerHeight', 'listOffset'], ['items', 'scrollTop', 'itemHeights', 'offsetHeight', 'showHeader', 'headerHeight', 'listOffset'],

View File

@ -63,7 +63,7 @@ test('Shortcut backspace goes back from favorites', async t => {
.expect(getUrl()).contains('/federated') .expect(getUrl()).contains('/federated')
.pressKey('g f') .pressKey('g f')
.expect(getUrl()).contains('/favorites') .expect(getUrl()).contains('/favorites')
.pressKey('Backspace') .pressKey('backspace')
.expect(getUrl()).contains('/federated') .expect(getUrl()).contains('/federated')
}) })
@ -88,7 +88,7 @@ test('Global shortcut has no effects while in modal dialog', async t => {
.expect(modalDialogContents.exists).ok() .expect(modalDialogContents.exists).ok()
.pressKey('s') // does nothing .pressKey('s') // does nothing
.expect(getUrl()).contains('/favorites') .expect(getUrl()).contains('/favorites')
.pressKey('Backspace') .pressKey('backspace')
.expect(modalDialogContents.exists).notOk() .expect(modalDialogContents.exists).notOk()
.pressKey('s') // now works .pressKey('s') // now works
.expect(getUrl()).contains('/search') .expect(getUrl()).contains('/search')

View File

@ -1,4 +1,3 @@
import { Selector as $ } from 'testcafe'
import { import {
closeDialogButton, closeDialogButton,
composeModalInput, composeModalInput,
@ -9,7 +8,8 @@ import {
getNthStatusSensitiveMediaButton, getNthStatusSensitiveMediaButton,
getNthStatusSpoiler, getNthStatusSpoiler,
getUrl, modalDialog, getUrl, modalDialog,
scrollToStatus scrollToStatus,
isNthStatusActive, getActiveElementRectTop
} from '../utils' } from '../utils'
import { homeTimeline } from '../fixtures' import { homeTimeline } from '../fixtures'
import { loginAsFoobar } from '../roles' import { loginAsFoobar } from '../roles'
@ -18,16 +18,12 @@ import { indexWhere } from '../../src/routes/_utils/arrays'
fixture`025-shortcuts-status.js` fixture`025-shortcuts-status.js`
.page`http://localhost:4002` .page`http://localhost:4002`
function isNthStatusActive (idx) {
return getNthStatus(idx).hasClass('status-active')
}
async function activateStatus (t, idx) { async function activateStatus (t, idx) {
let timeout = 20000 let timeout = 20000
for (let i = 0; i <= idx; i++) { for (let i = 0; i <= idx; i++) {
await t.expect(getNthStatus(i).exists).ok({ timeout }) await t.expect(getNthStatus(i).exists).ok({ timeout })
.pressKey('j') .pressKey('j')
.expect(getNthStatus(i).hasClass('status-active')).ok() .expect(isNthStatusActive(i)()).ok()
} }
} }
@ -36,24 +32,24 @@ test('Shortcut j/k change the active status', async t => {
await t await t
.expect(getUrl()).eql('http://localhost:4002/') .expect(getUrl()).eql('http://localhost:4002/')
.expect(getNthStatus(0).exists).ok({ timeout: 30000 }) .expect(getNthStatus(0).exists).ok({ timeout: 30000 })
.expect(isNthStatusActive(0)).notOk() .expect(isNthStatusActive(0)()).notOk()
.pressKey('j') .pressKey('j')
.expect(isNthStatusActive(0)).ok() .expect(isNthStatusActive(0)()).ok()
.pressKey('j') .pressKey('j')
.expect(isNthStatusActive(1)).ok() .expect(isNthStatusActive(1)()).ok()
.pressKey('j') .pressKey('j')
.expect(isNthStatusActive(2)).ok() .expect(isNthStatusActive(2)()).ok()
.pressKey('j') .pressKey('j')
.expect(isNthStatusActive(3)).ok() .expect(isNthStatusActive(3)()).ok()
.pressKey('k') .pressKey('k')
.expect(isNthStatusActive(2)).ok() .expect(isNthStatusActive(2)()).ok()
.pressKey('k') .pressKey('k')
.expect(isNthStatusActive(1)).ok() .expect(isNthStatusActive(1)()).ok()
.pressKey('k') .pressKey('k')
.expect(isNthStatusActive(0)).ok() .expect(isNthStatusActive(0)()).ok()
.expect(isNthStatusActive(1)).notOk() .expect(isNthStatusActive(1)()).notOk()
.expect(isNthStatusActive(2)).notOk() .expect(isNthStatusActive(2)()).notOk()
.expect(isNthStatusActive(3)).notOk() .expect(isNthStatusActive(3)()).notOk()
}) })
test('Shortcut j goes to the first visible status', async t => { test('Shortcut j goes to the first visible status', async t => {
@ -64,8 +60,7 @@ test('Shortcut j goes to the first visible status', async t => {
await t await t
.expect(getNthStatus(10).exists).ok({ timeout: 30000 }) .expect(getNthStatus(10).exists).ok({ timeout: 30000 })
.pressKey('j') .pressKey('j')
.expect($('.status-active').exists).ok() .expect(getActiveElementRectTop()).gte(0)
.expect($('.status-active').getBoundingClientRectProperty('top')).gte(0)
}) })
test('Shortcut o opens active status, backspace goes back', async t => { test('Shortcut o opens active status, backspace goes back', async t => {
@ -76,11 +71,11 @@ test('Shortcut o opens active status, backspace goes back', async t => {
.pressKey('j') // activates status 0 .pressKey('j') // activates status 0
.pressKey('j') // activates status 1 .pressKey('j') // activates status 1
.pressKey('j') // activates status 2 .pressKey('j') // activates status 2
.expect(isNthStatusActive(2)).ok() .expect(isNthStatusActive(2)()).ok()
.pressKey('o') .pressKey('o')
.expect(getUrl()).contains('/statuses/') .expect(getUrl()).contains('/statuses/')
.pressKey('Backspace') .pressKey('Backspace')
.expect(isNthStatusActive(2)).ok() .expect(isNthStatusActive(2)()).ok()
}) })
test('Shortcut x shows/hides spoilers', async t => { test('Shortcut x shows/hides spoilers', async t => {
@ -90,7 +85,7 @@ test('Shortcut x shows/hides spoilers', async t => {
.expect(getUrl()).eql('http://localhost:4002/') .expect(getUrl()).eql('http://localhost:4002/')
await activateStatus(t, idx) await activateStatus(t, idx)
await t await t
.expect(getNthStatus(idx).hasClass('status-active')).ok() .expect(isNthStatusActive(idx)()).ok()
.expect(getNthStatusSpoiler(idx).innerText).contains('kitten CW') .expect(getNthStatusSpoiler(idx).innerText).contains('kitten CW')
.expect(getNthStatusContent(idx).hasClass('shown')).notOk() .expect(getNthStatusContent(idx).hasClass('shown')).notOk()
.pressKey('x') .pressKey('x')
@ -106,7 +101,7 @@ test('Shortcut y shows/hides sensitive image', async t => {
.expect(getUrl()).eql('http://localhost:4002/') .expect(getUrl()).eql('http://localhost:4002/')
await activateStatus(t, idx) await activateStatus(t, idx)
await t await t
.expect(getNthStatus(idx).hasClass('status-active')).ok() .expect(isNthStatusActive(idx)()).ok()
.expect(getNthStatusSensitiveMediaButton(idx).exists).ok() .expect(getNthStatusSensitiveMediaButton(idx).exists).ok()
.expect(getNthStatusMedia(idx).exists).notOk() .expect(getNthStatusMedia(idx).exists).notOk()
.pressKey('y') .pressKey('y')
@ -123,7 +118,7 @@ test('Shortcut f toggles favorite status', async t => {
.expect(getNthStatus(idx).exists).ok({ timeout: 30000 }) .expect(getNthStatus(idx).exists).ok({ timeout: 30000 })
.expect(getNthFavorited(idx)).eql('false') .expect(getNthFavorited(idx)).eql('false')
.pressKey('j '.repeat(idx + 1)) .pressKey('j '.repeat(idx + 1))
.expect(getNthStatus(idx).hasClass('status-active')).ok() .expect(isNthStatusActive(idx)()).ok()
.pressKey('f') .pressKey('f')
.expect(getNthFavorited(idx)).eql('true') .expect(getNthFavorited(idx)).eql('true')
.pressKey('f') .pressKey('f')
@ -137,7 +132,7 @@ test('Shortcut p toggles profile', async t => {
.expect(getUrl()).eql('http://localhost:4002/') .expect(getUrl()).eql('http://localhost:4002/')
.expect(getNthStatus(idx).exists).ok({ timeout: 30000 }) .expect(getNthStatus(idx).exists).ok({ timeout: 30000 })
.pressKey('j '.repeat(idx + 1)) .pressKey('j '.repeat(idx + 1))
.expect(getNthStatus(idx).hasClass('status-active')).ok() .expect(isNthStatusActive(idx)()).ok()
.pressKey('p') .pressKey('p')
.expect(getUrl()).contains('/accounts/3') .expect(getUrl()).contains('/accounts/3')
}) })
@ -149,7 +144,7 @@ test('Shortcut m toggles mention', async t => {
.expect(getUrl()).eql('http://localhost:4002/') .expect(getUrl()).eql('http://localhost:4002/')
.expect(getNthStatus(idx).exists).ok({ timeout: 30000 }) .expect(getNthStatus(idx).exists).ok({ timeout: 30000 })
.pressKey('j '.repeat(idx + 1)) .pressKey('j '.repeat(idx + 1))
.expect(getNthStatus(idx).hasClass('status-active')).ok() .expect(isNthStatusActive(idx)()).ok()
.pressKey('m') .pressKey('m')
.expect(composeModalInput.value).eql('@quux ') .expect(composeModalInput.value).eql('@quux ')
.click(closeDialogButton) .click(closeDialogButton)

View File

@ -3,7 +3,8 @@ import {
composeModalInput, composeModalInput,
getNthFavorited, getNthFavorited,
getNthStatus, getNthStatus,
getUrl, modalDialog, notificationsNavButton getUrl, modalDialog, notificationsNavButton,
isNthStatusActive, goBack
} from '../utils' } from '../utils'
import { loginAsFoobar } from '../roles' import { loginAsFoobar } from '../roles'
@ -20,7 +21,7 @@ test('Shortcut f toggles favorite status in notification', async t => {
.expect(getNthStatus(idx).exists).ok({ timeout: 30000 }) .expect(getNthStatus(idx).exists).ok({ timeout: 30000 })
.expect(getNthFavorited(idx)).eql('false') .expect(getNthFavorited(idx)).eql('false')
.pressKey('j '.repeat(idx + 1)) .pressKey('j '.repeat(idx + 1))
.expect(getNthStatus(idx).hasClass('status-active')).ok() .expect(isNthStatusActive(idx)()).ok()
.pressKey('f') .pressKey('f')
.expect(getNthFavorited(idx)).eql('true') .expect(getNthFavorited(idx)).eql('true')
.pressKey('f') .pressKey('f')
@ -36,9 +37,12 @@ test('Shortcut p toggles profile in a follow notification', async t => {
.expect(getUrl()).contains('/notifications') .expect(getUrl()).contains('/notifications')
.expect(getNthStatus(0).exists).ok({ timeout: 30000 }) .expect(getNthStatus(0).exists).ok({ timeout: 30000 })
.pressKey('j '.repeat(idx + 1)) .pressKey('j '.repeat(idx + 1))
.expect(getNthStatus(idx).hasClass('status-active')).ok() .expect(isNthStatusActive(idx)()).ok()
.pressKey('p') .pressKey('p')
.expect(getUrl()).contains('/accounts/3') .expect(getUrl()).contains('/accounts/3')
await goBack()
await t
.expect(isNthStatusActive(idx)()).ok() // focus preserved
}) })
test('Shortcut m toggles mention in a follow notification', async t => { test('Shortcut m toggles mention in a follow notification', async t => {
@ -50,7 +54,7 @@ test('Shortcut m toggles mention in a follow notification', async t => {
.expect(getUrl()).contains('/notifications') .expect(getUrl()).contains('/notifications')
.expect(getNthStatus(0).exists).ok({ timeout: 30000 }) .expect(getNthStatus(0).exists).ok({ timeout: 30000 })
.pressKey('j '.repeat(idx + 1)) .pressKey('j '.repeat(idx + 1))
.expect(getNthStatus(idx).hasClass('status-active')).ok() .expect(isNthStatusActive(idx)()).ok()
.pressKey('m') .pressKey('m')
.expect(composeModalInput.value).eql('@quux ') .expect(composeModalInput.value).eql('@quux ')
.click(closeDialogButton) .click(closeDialogButton)
@ -66,7 +70,7 @@ test('Shortcut p refers to booster in a boost notification', async t => {
.expect(getUrl()).contains('/notifications') .expect(getUrl()).contains('/notifications')
.expect(getNthStatus(0).exists).ok({ timeout: 30000 }) .expect(getNthStatus(0).exists).ok({ timeout: 30000 })
.pressKey('j '.repeat(idx + 1)) .pressKey('j '.repeat(idx + 1))
.expect(getNthStatus(idx).hasClass('status-active')).ok() .expect(isNthStatusActive(idx)()).ok()
.pressKey('p') .pressKey('p')
.expect(getUrl()).contains('/accounts/1') .expect(getUrl()).contains('/accounts/1')
}) })
@ -80,7 +84,7 @@ test('Shortcut m refers to favoriter in a favorite notification', async t => {
.expect(getUrl()).contains('/notifications') .expect(getUrl()).contains('/notifications')
.expect(getNthStatus(0).exists).ok({ timeout: 30000 }) .expect(getNthStatus(0).exists).ok({ timeout: 30000 })
.pressKey('j '.repeat(idx + 1)) .pressKey('j '.repeat(idx + 1))
.expect(getNthStatus(idx).hasClass('status-active')).ok() .expect(isNthStatusActive(idx)()).ok()
.pressKey('m') .pressKey('m')
.expect(composeModalInput.value).eql('@admin ') .expect(composeModalInput.value).eql('@admin ')
.click(closeDialogButton) .click(closeDialogButton)

View File

@ -88,6 +88,10 @@ export const getActiveElementInnerText = exec(() =>
(document.activeElement && document.activeElement.innerText) || '' (document.activeElement && document.activeElement.innerText) || ''
) )
export const getActiveElementRectTop = exec(() => (
(document.activeElement && document.activeElement.getBoundingClientRect().top) || -1
))
export const getActiveElementInsideNthStatus = exec(() => { export const getActiveElementInsideNthStatus = exec(() => {
let element = document.activeElement let element = document.activeElement
while (element) { while (element) {
@ -151,6 +155,13 @@ export const focus = (selector) => (exec(() => {
} }
})) }))
export const isNthStatusActive = (idx) => (exec(() => {
return document.activeElement &&
document.activeElement.getAttribute('aria-posinset') === idx.toString()
}, {
dependencies: { idx }
}))
export const scrollToBottom = exec(() => { export const scrollToBottom = exec(() => {
document.scrollingElement.scrollTop = document.scrollingElement.scrollHeight document.scrollingElement.scrollTop = document.scrollingElement.scrollHeight
}) })