test(vapor): vapor todomvc e2e test
This commit is contained in:
parent
8549a243a5
commit
ba0594de0b
|
@ -80,6 +80,32 @@ jobs:
|
||||||
- name: verify treeshaking
|
- name: verify treeshaking
|
||||||
run: node scripts/verify-treeshaking.js
|
run: node scripts/verify-treeshaking.js
|
||||||
|
|
||||||
|
e2e-vapor:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup cache for Chromium binary
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.cache/puppeteer
|
||||||
|
key: chromium-${{ hashFiles('pnpm-lock.yaml') }}
|
||||||
|
|
||||||
|
- name: Install pnpm
|
||||||
|
uses: pnpm/action-setup@v4.0.0
|
||||||
|
|
||||||
|
- name: Install Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version-file: '.node-version'
|
||||||
|
cache: 'pnpm'
|
||||||
|
|
||||||
|
- run: pnpm install
|
||||||
|
- run: node node_modules/puppeteer/install.mjs
|
||||||
|
|
||||||
|
- name: Run e2e tests
|
||||||
|
run: pnpm run test-e2e-vapor
|
||||||
|
|
||||||
lint-and-test-dts:
|
lint-and-test-dts:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
|
|
|
@ -1,223 +0,0 @@
|
||||||
import puppeteer, {
|
|
||||||
type Browser,
|
|
||||||
type ClickOptions,
|
|
||||||
type LaunchOptions,
|
|
||||||
type Page,
|
|
||||||
} from 'puppeteer'
|
|
||||||
|
|
||||||
export const E2E_TIMEOUT: number = 30 * 1000
|
|
||||||
|
|
||||||
const puppeteerOptions: LaunchOptions = {
|
|
||||||
args: process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : [],
|
|
||||||
headless: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxTries = 30
|
|
||||||
export const timeout = (n: number): Promise<any> =>
|
|
||||||
new Promise(r => setTimeout(r, n))
|
|
||||||
|
|
||||||
export async function expectByPolling(
|
|
||||||
poll: () => Promise<any>,
|
|
||||||
expected: string,
|
|
||||||
): Promise<void> {
|
|
||||||
for (let tries = 0; tries < maxTries; tries++) {
|
|
||||||
const actual = (await poll()) || ''
|
|
||||||
if (actual.indexOf(expected) > -1 || tries === maxTries - 1) {
|
|
||||||
expect(actual).toMatch(expected)
|
|
||||||
break
|
|
||||||
} else {
|
|
||||||
await timeout(50)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PuppeteerUtils {
|
|
||||||
page: () => Page
|
|
||||||
click(selector: string, options?: ClickOptions): Promise<void>
|
|
||||||
count(selector: string): Promise<number>
|
|
||||||
text(selector: string): Promise<string | null>
|
|
||||||
value(selector: string): Promise<string>
|
|
||||||
html(selector: string): Promise<string>
|
|
||||||
classList(selector: string): Promise<string[]>
|
|
||||||
style(selector: string, property: keyof CSSStyleDeclaration): Promise<any>
|
|
||||||
children(selector: string): Promise<any[]>
|
|
||||||
isVisible(selector: string): Promise<boolean>
|
|
||||||
isChecked(selector: string): Promise<boolean>
|
|
||||||
isFocused(selector: string): Promise<boolean>
|
|
||||||
setValue(selector: string, value: string): Promise<any>
|
|
||||||
typeValue(selector: string, value: string): Promise<any>
|
|
||||||
enterValue(selector: string, value: string): Promise<any>
|
|
||||||
clearValue(selector: string): Promise<any>
|
|
||||||
timeout(time: number): Promise<any>
|
|
||||||
nextFrame(): Promise<any>
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setupPuppeteer(args?: string[]): PuppeteerUtils {
|
|
||||||
let browser: Browser
|
|
||||||
let page: Page
|
|
||||||
|
|
||||||
const resolvedOptions = args
|
|
||||||
? {
|
|
||||||
...puppeteerOptions,
|
|
||||||
args: [...puppeteerOptions.args!, ...args],
|
|
||||||
}
|
|
||||||
: puppeteerOptions
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
browser = await puppeteer.launch(resolvedOptions)
|
|
||||||
}, 20000)
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
page = await browser.newPage()
|
|
||||||
|
|
||||||
await page.evaluateOnNewDocument(() => {
|
|
||||||
localStorage.clear()
|
|
||||||
})
|
|
||||||
|
|
||||||
page.on('console', e => {
|
|
||||||
if (e.type() === 'error') {
|
|
||||||
console.error(`Error from Puppeteer-loaded page:\n`, e.text())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await page.close()
|
|
||||||
})
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await browser.close()
|
|
||||||
})
|
|
||||||
|
|
||||||
async function click(
|
|
||||||
selector: string,
|
|
||||||
options?: ClickOptions,
|
|
||||||
): Promise<void> {
|
|
||||||
await page.click(selector, options)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function count(selector: string): Promise<number> {
|
|
||||||
return (await page.$$(selector)).length
|
|
||||||
}
|
|
||||||
|
|
||||||
async function text(selector: string): Promise<string | null> {
|
|
||||||
return page.$eval(selector, node => node.textContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function value(selector: string): Promise<string> {
|
|
||||||
return page.$eval(selector, node => (node as HTMLInputElement).value)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function html(selector: string): Promise<string> {
|
|
||||||
return page.$eval(selector, node => node.innerHTML)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function classList(selector: string): Promise<string[]> {
|
|
||||||
return page.$eval(selector, (node: any) => [...node.classList])
|
|
||||||
}
|
|
||||||
|
|
||||||
async function children(selector: string): Promise<any[]> {
|
|
||||||
return page.$eval(selector, (node: any) => [...node.children])
|
|
||||||
}
|
|
||||||
|
|
||||||
async function style(
|
|
||||||
selector: string,
|
|
||||||
property: keyof CSSStyleDeclaration,
|
|
||||||
): Promise<any> {
|
|
||||||
return await page.$eval(
|
|
||||||
selector,
|
|
||||||
(node, property) => {
|
|
||||||
return window.getComputedStyle(node)[property]
|
|
||||||
},
|
|
||||||
property,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function isVisible(selector: string): Promise<boolean> {
|
|
||||||
const display = await page.$eval(selector, node => {
|
|
||||||
return window.getComputedStyle(node).display
|
|
||||||
})
|
|
||||||
return display !== 'none'
|
|
||||||
}
|
|
||||||
|
|
||||||
async function isChecked(selector: string) {
|
|
||||||
return await page.$eval(
|
|
||||||
selector,
|
|
||||||
node => (node as HTMLInputElement).checked,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function isFocused(selector: string) {
|
|
||||||
return await page.$eval(selector, node => node === document.activeElement)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setValue(selector: string, value: string) {
|
|
||||||
await page.$eval(
|
|
||||||
selector,
|
|
||||||
(node, value) => {
|
|
||||||
;(node as HTMLInputElement).value = value as string
|
|
||||||
node.dispatchEvent(new Event('input'))
|
|
||||||
},
|
|
||||||
value,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function typeValue(selector: string, value: string) {
|
|
||||||
const el = (await page.$(selector))!
|
|
||||||
await el.evaluate(node => ((node as HTMLInputElement).value = ''))
|
|
||||||
await el.type(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function enterValue(selector: string, value: string) {
|
|
||||||
const el = (await page.$(selector))!
|
|
||||||
await el.evaluate(node => ((node as HTMLInputElement).value = ''))
|
|
||||||
await el.type(value)
|
|
||||||
await el.press('Enter')
|
|
||||||
}
|
|
||||||
|
|
||||||
async function clearValue(selector: string) {
|
|
||||||
return await page.$eval(
|
|
||||||
selector,
|
|
||||||
node => ((node as HTMLInputElement).value = ''),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function timeout(time: number) {
|
|
||||||
return page.evaluate(time => {
|
|
||||||
return new Promise(r => {
|
|
||||||
setTimeout(r, time)
|
|
||||||
})
|
|
||||||
}, time)
|
|
||||||
}
|
|
||||||
|
|
||||||
function nextFrame() {
|
|
||||||
return page.evaluate(() => {
|
|
||||||
return new Promise(resolve => {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
requestAnimationFrame(resolve)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
page: () => page,
|
|
||||||
click,
|
|
||||||
count,
|
|
||||||
text,
|
|
||||||
value,
|
|
||||||
html,
|
|
||||||
classList,
|
|
||||||
style,
|
|
||||||
children,
|
|
||||||
isVisible,
|
|
||||||
isChecked,
|
|
||||||
isFocused,
|
|
||||||
setValue,
|
|
||||||
typeValue,
|
|
||||||
enterValue,
|
|
||||||
clearValue,
|
|
||||||
timeout,
|
|
||||||
nextFrame,
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1 +1,195 @@
|
||||||
test('bar', () => {})
|
import path from 'node:path'
|
||||||
|
import {
|
||||||
|
E2E_TIMEOUT,
|
||||||
|
setupPuppeteer,
|
||||||
|
} from '../../../packages/vue/__tests__/e2e/e2eUtils'
|
||||||
|
import connect from 'connect'
|
||||||
|
import sirv from 'sirv'
|
||||||
|
|
||||||
|
describe('e2e: todomvc', () => {
|
||||||
|
const {
|
||||||
|
page,
|
||||||
|
click,
|
||||||
|
isVisible,
|
||||||
|
count,
|
||||||
|
text,
|
||||||
|
value,
|
||||||
|
isChecked,
|
||||||
|
isFocused,
|
||||||
|
classList,
|
||||||
|
enterValue,
|
||||||
|
clearValue,
|
||||||
|
timeout,
|
||||||
|
} = setupPuppeteer()
|
||||||
|
|
||||||
|
let server: any
|
||||||
|
const port = '8194'
|
||||||
|
beforeAll(() => {
|
||||||
|
server = connect()
|
||||||
|
.use(sirv(path.resolve(import.meta.dirname, '../dist')))
|
||||||
|
.listen(port)
|
||||||
|
process.on('SIGTERM', () => server && server.close())
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
server.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function removeItemAt(n: number) {
|
||||||
|
const item = (await page().$('.todo:nth-child(' + n + ')'))!
|
||||||
|
const itemBBox = (await item.boundingBox())!
|
||||||
|
await page().mouse.move(itemBBox.x + 10, itemBBox.y + 10)
|
||||||
|
await click('.todo:nth-child(' + n + ') .destroy')
|
||||||
|
}
|
||||||
|
|
||||||
|
test(
|
||||||
|
'vapor',
|
||||||
|
async () => {
|
||||||
|
const baseUrl = `http://localhost:${port}/todomvc/`
|
||||||
|
await page().goto(baseUrl)
|
||||||
|
|
||||||
|
expect(await isVisible('.main')).toBe(false)
|
||||||
|
expect(await isVisible('.footer')).toBe(false)
|
||||||
|
expect(await count('.filters .selected')).toBe(1)
|
||||||
|
expect(await text('.filters .selected')).toBe('All')
|
||||||
|
expect(await count('.todo')).toBe(0)
|
||||||
|
|
||||||
|
await enterValue('.new-todo', 'test')
|
||||||
|
expect(await count('.todo')).toBe(1)
|
||||||
|
expect(await isVisible('.todo .edit')).toBe(false)
|
||||||
|
expect(await text('.todo label')).toBe('test')
|
||||||
|
expect(await text('.todo-count strong')).toBe('1')
|
||||||
|
expect(await isChecked('.todo .toggle')).toBe(false)
|
||||||
|
expect(await isVisible('.main')).toBe(true)
|
||||||
|
expect(await isVisible('.footer')).toBe(true)
|
||||||
|
expect(await isVisible('.clear-completed')).toBe(false)
|
||||||
|
expect(await value('.new-todo')).toBe('')
|
||||||
|
|
||||||
|
await enterValue('.new-todo', 'test2')
|
||||||
|
expect(await count('.todo')).toBe(2)
|
||||||
|
expect(await text('.todo:nth-child(2) label')).toBe('test2')
|
||||||
|
expect(await text('.todo-count strong')).toBe('2')
|
||||||
|
|
||||||
|
// toggle
|
||||||
|
await click('.todo .toggle')
|
||||||
|
expect(await count('.todo.completed')).toBe(1)
|
||||||
|
expect(await classList('.todo:nth-child(1)')).toContain('completed')
|
||||||
|
expect(await text('.todo-count strong')).toBe('1')
|
||||||
|
expect(await isVisible('.clear-completed')).toBe(true)
|
||||||
|
|
||||||
|
await enterValue('.new-todo', 'test3')
|
||||||
|
expect(await count('.todo')).toBe(3)
|
||||||
|
expect(await text('.todo:nth-child(3) label')).toBe('test3')
|
||||||
|
expect(await text('.todo-count strong')).toBe('2')
|
||||||
|
|
||||||
|
await enterValue('.new-todo', 'test4')
|
||||||
|
await enterValue('.new-todo', 'test5')
|
||||||
|
expect(await count('.todo')).toBe(5)
|
||||||
|
expect(await text('.todo-count strong')).toBe('4')
|
||||||
|
|
||||||
|
// toggle more
|
||||||
|
await click('.todo:nth-child(4) .toggle')
|
||||||
|
await click('.todo:nth-child(5) .toggle')
|
||||||
|
expect(await count('.todo.completed')).toBe(3)
|
||||||
|
expect(await text('.todo-count strong')).toBe('2')
|
||||||
|
|
||||||
|
// remove
|
||||||
|
await removeItemAt(1)
|
||||||
|
expect(await count('.todo')).toBe(4)
|
||||||
|
expect(await count('.todo.completed')).toBe(2)
|
||||||
|
expect(await text('.todo-count strong')).toBe('2')
|
||||||
|
await removeItemAt(2)
|
||||||
|
expect(await count('.todo')).toBe(3)
|
||||||
|
expect(await count('.todo.completed')).toBe(2)
|
||||||
|
expect(await text('.todo-count strong')).toBe('1')
|
||||||
|
|
||||||
|
// remove all
|
||||||
|
await click('.clear-completed')
|
||||||
|
expect(await count('.todo')).toBe(1)
|
||||||
|
expect(await text('.todo label')).toBe('test2')
|
||||||
|
expect(await count('.todo.completed')).toBe(0)
|
||||||
|
expect(await text('.todo-count strong')).toBe('1')
|
||||||
|
expect(await isVisible('.clear-completed')).toBe(false)
|
||||||
|
|
||||||
|
// prepare to test filters
|
||||||
|
await enterValue('.new-todo', 'test')
|
||||||
|
await enterValue('.new-todo', 'test')
|
||||||
|
await click('.todo:nth-child(2) .toggle')
|
||||||
|
await click('.todo:nth-child(3) .toggle')
|
||||||
|
|
||||||
|
// active filter
|
||||||
|
await click('.filters li:nth-child(2) a')
|
||||||
|
await timeout(1)
|
||||||
|
expect(await count('.todo')).toBe(1)
|
||||||
|
expect(await count('.todo.completed')).toBe(0)
|
||||||
|
// add item with filter active
|
||||||
|
await enterValue('.new-todo', 'test')
|
||||||
|
expect(await count('.todo')).toBe(2)
|
||||||
|
|
||||||
|
// completed filter
|
||||||
|
await click('.filters li:nth-child(3) a')
|
||||||
|
await timeout(1)
|
||||||
|
expect(await count('.todo')).toBe(2)
|
||||||
|
expect(await count('.todo.completed')).toBe(2)
|
||||||
|
|
||||||
|
// filter on page load
|
||||||
|
await page().goto(`${baseUrl}#active`)
|
||||||
|
expect(await count('.todo')).toBe(2)
|
||||||
|
expect(await count('.todo.completed')).toBe(0)
|
||||||
|
expect(await text('.todo-count strong')).toBe('2')
|
||||||
|
|
||||||
|
// completed on page load
|
||||||
|
await page().goto(`${baseUrl}#completed`)
|
||||||
|
expect(await count('.todo')).toBe(2)
|
||||||
|
expect(await count('.todo.completed')).toBe(2)
|
||||||
|
expect(await text('.todo-count strong')).toBe('2')
|
||||||
|
|
||||||
|
// toggling with filter active
|
||||||
|
await click('.todo .toggle')
|
||||||
|
expect(await count('.todo')).toBe(1)
|
||||||
|
await click('.filters li:nth-child(2) a')
|
||||||
|
await timeout(1)
|
||||||
|
expect(await count('.todo')).toBe(3)
|
||||||
|
await click('.todo .toggle')
|
||||||
|
expect(await count('.todo')).toBe(2)
|
||||||
|
|
||||||
|
// editing triggered by blur
|
||||||
|
await click('.filters li:nth-child(1) a')
|
||||||
|
await timeout(1)
|
||||||
|
await click('.todo:nth-child(1) label', { clickCount: 2 })
|
||||||
|
expect(await count('.todo.editing')).toBe(1)
|
||||||
|
expect(await isFocused('.todo:nth-child(1) .edit')).toBe(true)
|
||||||
|
await clearValue('.todo:nth-child(1) .edit')
|
||||||
|
await page().type('.todo:nth-child(1) .edit', 'edited!')
|
||||||
|
await click('.new-todo') // blur
|
||||||
|
expect(await count('.todo.editing')).toBe(0)
|
||||||
|
expect(await text('.todo:nth-child(1) label')).toBe('edited!')
|
||||||
|
|
||||||
|
// editing triggered by enter
|
||||||
|
await click('.todo label', { clickCount: 2 })
|
||||||
|
await enterValue('.todo:nth-child(1) .edit', 'edited again!')
|
||||||
|
expect(await count('.todo.editing')).toBe(0)
|
||||||
|
expect(await text('.todo:nth-child(1) label')).toBe('edited again!')
|
||||||
|
|
||||||
|
// cancel
|
||||||
|
await click('.todo label', { clickCount: 2 })
|
||||||
|
await clearValue('.todo:nth-child(1) .edit')
|
||||||
|
await page().type('.todo:nth-child(1) .edit', 'edited!')
|
||||||
|
await page().keyboard.press('Escape')
|
||||||
|
expect(await count('.todo.editing')).toBe(0)
|
||||||
|
expect(await text('.todo:nth-child(1) label')).toBe('edited again!')
|
||||||
|
|
||||||
|
// empty value should remove
|
||||||
|
await click('.todo label', { clickCount: 2 })
|
||||||
|
await enterValue('.todo:nth-child(1) .edit', ' ')
|
||||||
|
expect(await count('.todo')).toBe(3)
|
||||||
|
|
||||||
|
// toggle all
|
||||||
|
await click('.toggle-all+label')
|
||||||
|
expect(await count('.todo.completed')).toBe(3)
|
||||||
|
await click('.toggle-all+label')
|
||||||
|
expect(await count('.todo:not(.completed)')).toBe(3)
|
||||||
|
},
|
||||||
|
E2E_TIMEOUT,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
|
@ -25,7 +25,7 @@ describe('vdom / vapor interop', () => {
|
||||||
test(
|
test(
|
||||||
'should work',
|
'should work',
|
||||||
async () => {
|
async () => {
|
||||||
const baseUrl = `http://localhost:${port}/interop`
|
const baseUrl = `http://localhost:${port}/interop/`
|
||||||
await page().goto(baseUrl)
|
await page().goto(baseUrl)
|
||||||
|
|
||||||
expect(await text('.vapor > h2')).toContain('Vapor component in VDOM')
|
expect(await text('.vapor > h2')).toContain('Vapor component in VDOM')
|
||||||
|
|
|
@ -0,0 +1,230 @@
|
||||||
|
<script setup vapor>
|
||||||
|
import {
|
||||||
|
reactive,
|
||||||
|
computed,
|
||||||
|
watchEffect,
|
||||||
|
onMounted,
|
||||||
|
onUnmounted,
|
||||||
|
next,
|
||||||
|
nextTick,
|
||||||
|
} from 'vue'
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'todos-vuejs-3.x'
|
||||||
|
|
||||||
|
const todoStorage = {
|
||||||
|
fetch() {
|
||||||
|
const todos = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]')
|
||||||
|
todos.forEach((todo, index) => {
|
||||||
|
todo.id = index
|
||||||
|
})
|
||||||
|
todoStorage.uid = todos.length
|
||||||
|
return todos
|
||||||
|
},
|
||||||
|
save(todos) {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(todos))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters = {
|
||||||
|
all(todos) {
|
||||||
|
return todos
|
||||||
|
},
|
||||||
|
active(todos) {
|
||||||
|
return todos.filter(todo => {
|
||||||
|
return !todo.completed
|
||||||
|
})
|
||||||
|
},
|
||||||
|
completed(todos) {
|
||||||
|
return todos.filter(function (todo) {
|
||||||
|
return todo.completed
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
function pluralize(n) {
|
||||||
|
return n === 1 ? 'item' : 'items'
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = reactive({
|
||||||
|
todos: todoStorage.fetch(),
|
||||||
|
editedTodo: null,
|
||||||
|
newTodo: '',
|
||||||
|
beforeEditCache: '',
|
||||||
|
visibility: 'all',
|
||||||
|
remaining: computed(() => {
|
||||||
|
return filters.active(state.todos).length
|
||||||
|
}),
|
||||||
|
remainingText: computed(() => {
|
||||||
|
return ` ${pluralize(state.remaining)} left`
|
||||||
|
}),
|
||||||
|
filteredTodos: computed(() => {
|
||||||
|
return filters[state.visibility](state.todos)
|
||||||
|
}),
|
||||||
|
allDone: computed({
|
||||||
|
get: function () {
|
||||||
|
return state.remaining === 0
|
||||||
|
},
|
||||||
|
set: function (value) {
|
||||||
|
state.todos.forEach(todo => {
|
||||||
|
todo.completed = value
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
watchEffect(() => {
|
||||||
|
todoStorage.save(state.todos)
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('hashchange', onHashChange)
|
||||||
|
onHashChange()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('hashchange', onHashChange)
|
||||||
|
})
|
||||||
|
|
||||||
|
function onHashChange() {
|
||||||
|
const visibility = window.location.hash.replace(/#\/?/, '')
|
||||||
|
if (filters[visibility]) {
|
||||||
|
state.visibility = visibility
|
||||||
|
} else {
|
||||||
|
window.location.hash = ''
|
||||||
|
state.visibility = 'all'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addTodo() {
|
||||||
|
const value = state.newTodo && state.newTodo.trim()
|
||||||
|
if (!value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state.todos.push({
|
||||||
|
id: todoStorage.uid++,
|
||||||
|
title: value,
|
||||||
|
completed: false,
|
||||||
|
})
|
||||||
|
state.newTodo = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeTodo(todo) {
|
||||||
|
state.todos.splice(state.todos.indexOf(todo), 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function editTodo(todo) {
|
||||||
|
state.beforeEditCache = todo.title
|
||||||
|
state.editedTodo = todo
|
||||||
|
}
|
||||||
|
|
||||||
|
function doneEdit(todo) {
|
||||||
|
if (!state.editedTodo) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state.editedTodo = null
|
||||||
|
todo.title = todo.title.trim()
|
||||||
|
if (!todo.title) {
|
||||||
|
removeTodo(todo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit(todo) {
|
||||||
|
state.editedTodo = null
|
||||||
|
todo.title = state.beforeEditCache
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeCompleted() {
|
||||||
|
state.todos = filters.active(state.todos)
|
||||||
|
}
|
||||||
|
|
||||||
|
// vapor custom directive
|
||||||
|
const vTodoFocus = (el, value) => () => {
|
||||||
|
if (value()) nextTick(() => el.focus())
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="todoapp">
|
||||||
|
<header class="header">
|
||||||
|
<h1>todos</h1>
|
||||||
|
<input
|
||||||
|
class="new-todo"
|
||||||
|
autofocus
|
||||||
|
autocomplete="off"
|
||||||
|
placeholder="What needs to be done?"
|
||||||
|
v-model="state.newTodo"
|
||||||
|
@keyup.enter="addTodo"
|
||||||
|
/>
|
||||||
|
</header>
|
||||||
|
<section class="main" v-show="state.todos.length">
|
||||||
|
<input
|
||||||
|
id="toggle-all"
|
||||||
|
class="toggle-all"
|
||||||
|
type="checkbox"
|
||||||
|
v-model="state.allDone"
|
||||||
|
/>
|
||||||
|
<label for="toggle-all">Mark all as complete</label>
|
||||||
|
<ul class="todo-list">
|
||||||
|
<li
|
||||||
|
v-for="todo in state.filteredTodos"
|
||||||
|
class="todo"
|
||||||
|
:key="todo.id"
|
||||||
|
:class="{
|
||||||
|
completed: todo.completed,
|
||||||
|
editing: todo === state.editedTodo,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div class="view">
|
||||||
|
<input class="toggle" type="checkbox" v-model="todo.completed" />
|
||||||
|
<label @dblclick="editTodo(todo)">{{ todo.title }}</label>
|
||||||
|
<button class="destroy" @click="removeTodo(todo)"></button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
class="edit"
|
||||||
|
type="text"
|
||||||
|
v-model="todo.title"
|
||||||
|
v-todo-focus="todo === state.editedTodo"
|
||||||
|
@blur="doneEdit(todo)"
|
||||||
|
@keyup.enter="doneEdit(todo)"
|
||||||
|
@keyup.escape="cancelEdit(todo)"
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
<footer class="footer" v-show="state.todos.length">
|
||||||
|
<span class="todo-count">
|
||||||
|
<strong>{{ state.remaining }}</strong>
|
||||||
|
<span>{{ state.remainingText }}</span>
|
||||||
|
</span>
|
||||||
|
<ul class="filters">
|
||||||
|
<li>
|
||||||
|
<a href="#/all" :class="{ selected: state.visibility === 'all' }"
|
||||||
|
>All</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="#/active"
|
||||||
|
:class="{ selected: state.visibility === 'active' }"
|
||||||
|
>Active</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="#/completed"
|
||||||
|
:class="{ selected: state.visibility === 'completed' }"
|
||||||
|
>Completed</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="clear-completed"
|
||||||
|
@click="removeCompleted"
|
||||||
|
v-show="state.todos.length > state.remaining"
|
||||||
|
>
|
||||||
|
Clear completed
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
</template>
|
|
@ -1 +1,2 @@
|
||||||
<script type="module" src="./main.ts"></script>
|
<script type="module" src="./main.ts"></script>
|
||||||
|
<div id="app"></div>
|
||||||
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { createVaporApp } from 'vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
import 'todomvc-app-css/index.css'
|
||||||
|
|
||||||
|
createVaporApp(App).mount('#app')
|
Loading…
Reference in New Issue