├── .nvmrc ├── packages ├── test-lib │ ├── src │ │ ├── bin │ │ │ └── index.js │ │ ├── client │ │ │ ├── readable.js │ │ │ ├── index.js │ │ │ ├── socket-sink.js │ │ │ ├── test.js │ │ │ ├── html-reporter │ │ │ │ ├── html-repoter.js │ │ │ │ ├── diagnostic.component.js │ │ │ │ └── test-result.component.js │ │ │ └── theme.css │ │ └── vite │ │ │ └── index.js │ ├── package.json │ └── package-lock.json ├── core │ ├── src │ │ ├── index.js │ │ ├── define.js │ │ ├── index.d.ts │ │ └── component.js │ ├── test │ │ ├── utils.js │ │ ├── shadow-dom.test.js │ │ ├── test-suite.html │ │ ├── abort-signal.test.js │ │ ├── simple-component.test.js │ │ ├── run-ci.js │ │ └── reactive-attributes.test.js │ ├── vite.config.js │ ├── package.json │ └── readme.md ├── controllers │ ├── src │ │ ├── index.js │ │ ├── props.js │ │ ├── index.d.ts │ │ └── controller.js │ ├── test │ │ ├── utils.js │ │ ├── test-suite.html │ │ ├── run-ci.js │ │ ├── controller.js │ │ └── props.js │ ├── vite.config.js │ ├── package.json │ └── readme.md ├── di │ ├── test │ │ ├── utils.js │ │ ├── test-suite.html │ │ ├── run-ci.js │ │ └── injector.test.js │ ├── vite.config.js │ ├── package-lock.json │ ├── src │ │ ├── injector.js │ │ └── index.js │ ├── package.json │ └── readme.md └── view │ ├── vite.config.js │ ├── readme.md │ ├── test │ ├── utils.js │ ├── test-suite.html │ ├── run-ci.js │ ├── if-dom.test.js │ ├── attributes.test.js │ ├── event-listeners.test.js │ └── simple-render.test.js │ ├── package.json │ └── src │ ├── index.d.ts │ ├── index.js │ ├── range.js │ └── tree.js ├── pnpm-workspace.yaml ├── .gitignore ├── apps ├── restaurant-cashier │ ├── config.js │ ├── favicon.ico │ ├── router │ │ ├── index.js │ │ ├── utils.js │ │ ├── page-link.component.js │ │ ├── page-outlet.component.js │ │ ├── trie.js │ │ └── router.js │ ├── vite.config.js │ ├── not-available.page.js │ ├── dashboard │ │ ├── dashboard.controller.js │ │ ├── top-items-chart.component.js │ │ ├── top-items-revenue-chart.component.js │ │ ├── cart-count-chart.component.js │ │ ├── revenues-chart.component.js │ │ └── dashboard.page.js │ ├── utils │ │ ├── storage.service.js │ │ ├── dom.js │ │ ├── functions.js │ │ ├── events.service.js │ │ ├── objects.js │ │ ├── http.js │ │ ├── notifications.service.js │ │ └── animations.service.js │ ├── products │ │ ├── image-uploader │ │ │ ├── images.service.js │ │ │ └── image-uploader.component.js │ │ ├── product-list.model.js │ │ ├── product-list.controller.js │ │ ├── list │ │ │ ├── product-list.page.js │ │ │ ├── product-list-item.component.js │ │ │ └── product-list.component.js │ │ ├── product-list.service.js │ │ ├── new │ │ │ └── new-product.page.js │ │ ├── products.css │ │ └── edit │ │ │ └── edit-product.page.js │ ├── logo.svg │ ├── package.json │ ├── readme.md │ ├── theme │ │ ├── reset.css │ │ ├── layout.css │ │ ├── button.css │ │ └── form.css │ ├── cart │ │ ├── cart.controller.js │ │ ├── cart-product-item.component.js │ │ ├── cart.page.js │ │ ├── cart-product-list.component.js │ │ ├── cart.component.js │ │ ├── cart.service.js │ │ └── cart.css │ ├── users │ │ ├── preferences.controller.js │ │ ├── me.page.js │ │ ├── me.css │ │ ├── preferences.service.js │ │ └── preferences.component.js │ ├── components │ │ ├── ui-icon.component.js │ │ ├── ui-label.component.js │ │ ├── ui-alert.component.js │ │ └── ui-listbox.component.js │ ├── index.js │ ├── index.html │ └── app.js ├── open-library │ ├── index.js │ ├── readme.md │ ├── package.json │ ├── view.js │ ├── search.service.js │ ├── index.html │ ├── book-list.controller.js │ └── book-list.component.js └── todomvc │ ├── readme.md │ ├── package.json │ ├── tsconfig.json │ ├── utils.ts │ ├── components │ ├── ui-icon.component.js │ └── icons.svg │ ├── todo-list.controller.ts │ ├── views │ ├── add-todo.view.ts │ ├── todo-list.view.ts │ ├── todo-item.view.ts │ └── todo-list-controls.view.ts │ ├── app.ts │ ├── index.html │ ├── todo.model.ts │ ├── todo.service.ts │ └── theme.css ├── package.json ├── .github └── workflows │ └── test.yml └── readme.md /.nvmrc: -------------------------------------------------------------------------------- 1 | v20 -------------------------------------------------------------------------------- /packages/test-lib/src/bin/index.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/test-lib/src/client/readable.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - 'packages/*' 3 | - 'apps/*' -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | node_modules 3 | apps/**/dist 4 | .DS_Store 5 | coverage 6 | dist 7 | -------------------------------------------------------------------------------- /apps/restaurant-cashier/config.js: -------------------------------------------------------------------------------- 1 | export const APIRootURL = 'http://localhost:5173/api/'; 2 | -------------------------------------------------------------------------------- /packages/core/src/index.js: -------------------------------------------------------------------------------- 1 | export * from './component.js'; 2 | export * from './define.js'; 3 | -------------------------------------------------------------------------------- /packages/controllers/src/index.js: -------------------------------------------------------------------------------- 1 | export * from './controller'; 2 | export * from './props.js'; 3 | -------------------------------------------------------------------------------- /packages/core/test/utils.js: -------------------------------------------------------------------------------- 1 | export const nextTick = () => new Promise((resolve) => setTimeout(resolve, 0)); 2 | -------------------------------------------------------------------------------- /packages/controllers/test/utils.js: -------------------------------------------------------------------------------- 1 | export const nextTick = () => new Promise((resolve) => setTimeout(resolve, 0)); 2 | -------------------------------------------------------------------------------- /apps/restaurant-cashier/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lorenzofox3/cofn/main/apps/restaurant-cashier/favicon.ico -------------------------------------------------------------------------------- /apps/restaurant-cashier/router/index.js: -------------------------------------------------------------------------------- 1 | import { createRouter } from './router.js'; 2 | 3 | export const defaultRouter = createRouter(); 4 | -------------------------------------------------------------------------------- /apps/restaurant-cashier/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | 3 | export default defineConfig({ 4 | plugins: [], 5 | }); 6 | -------------------------------------------------------------------------------- /packages/test-lib/src/client/index.js: -------------------------------------------------------------------------------- 1 | export * from './test.js'; 2 | export * from './socket-sink.js'; 3 | export * from './html-reporter/html-repoter.js'; 4 | -------------------------------------------------------------------------------- /packages/di/test/utils.js: -------------------------------------------------------------------------------- 1 | import { define } from '@cofn/core'; 2 | export const nextTick = () => new Promise((resolve) => setTimeout(resolve, 0)); 3 | 4 | let compCount = 0; 5 | -------------------------------------------------------------------------------- /apps/open-library/index.js: -------------------------------------------------------------------------------- 1 | import { define } from '@cofn/core'; 2 | import { BookListComponent } from './book-list.component.js'; 3 | 4 | define('app-book-search', BookListComponent); 5 | -------------------------------------------------------------------------------- /packages/core/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import zoraDev from '@cofn/test-lib/vite'; 3 | 4 | export default defineConfig({ 5 | plugins: [zoraDev()], 6 | }); 7 | -------------------------------------------------------------------------------- /packages/di/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import zoraDev from '@cofn/test-lib/vite'; 3 | 4 | export default defineConfig({ 5 | plugins: [zoraDev()], 6 | }); 7 | -------------------------------------------------------------------------------- /packages/view/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import zoraDev from '@cofn/test-lib/vite'; 3 | 4 | export default defineConfig({ 5 | plugins: [zoraDev()], 6 | }); 7 | -------------------------------------------------------------------------------- /packages/controllers/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import zoraDev from '@cofn/test-lib/vite'; 3 | 4 | export default defineConfig({ 5 | plugins: [zoraDev()], 6 | }); 7 | -------------------------------------------------------------------------------- /apps/open-library/readme.md: -------------------------------------------------------------------------------- 1 | Use the [open library](https://openlibrary.org) API to find information about a book. 2 | 3 | From the technical perspective, it shows how to use a basic controller. 4 | -------------------------------------------------------------------------------- /apps/restaurant-cashier/not-available.page.js: -------------------------------------------------------------------------------- 1 | import { createElement } from './utils/dom.js'; 2 | 3 | export const loadPage = () => { 4 | const p = createElement('p'); 5 | p.textContent = 'not yet implemented'; 6 | return p; 7 | }; 8 | -------------------------------------------------------------------------------- /packages/view/readme.md: -------------------------------------------------------------------------------- 1 | # View 2 | 3 | Build component using a declarative view 4 | 5 | ## Installation 6 | 7 | you can install the library with a package manager (like npm): 8 | ``npm install @cofn/view`` 9 | 10 | Or import it directly from a CDN 11 | -------------------------------------------------------------------------------- /packages/di/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@cofn/di", 3 | "version": "1.0.0", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "@cofn/di", 9 | "version": "1.0.0", 10 | "license": "MIT" 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /packages/test-lib/src/client/socket-sink.js: -------------------------------------------------------------------------------- 1 | export const createSocketSink = (ws) => { 2 | return new WritableStream({ 3 | start() { 4 | ws.send('zora', { type: 'STREAM_STARTED' }); 5 | }, 6 | write(chunk) { 7 | ws.send('zora', chunk); 8 | }, 9 | close() { 10 | ws.send('zora', { type: 'STREAM_ENDED' }); 11 | }, 12 | }); 13 | }; 14 | -------------------------------------------------------------------------------- /apps/restaurant-cashier/dashboard/dashboard.controller.js: -------------------------------------------------------------------------------- 1 | import { http } from '../utils/http.js'; 2 | import { compose } from '../utils/functions.js'; 3 | 4 | export const withChartData = (comp) => { 5 | return function* ({ $host, $signal, ...rest }) { 6 | http($host.dataset.url, { signal: $signal }).then($host.render); 7 | yield* comp({ $host, $signal, ...rest }); 8 | }; 9 | }; 10 | -------------------------------------------------------------------------------- /packages/view/test/utils.js: -------------------------------------------------------------------------------- 1 | import { define } from '@cofn/core'; 2 | import { withView } from '../src/index.js'; 3 | export const nextTick = () => new Promise((resolve) => setTimeout(resolve, 0)); 4 | 5 | let compCount = 0; 6 | export const fromView = (view) => { 7 | const tag = `view-test-${++compCount}`; 8 | define(tag, withView(view)); 9 | return document.createElement(tag); 10 | }; 11 | -------------------------------------------------------------------------------- /apps/restaurant-cashier/utils/storage.service.js: -------------------------------------------------------------------------------- 1 | import { mapBind } from './objects.js'; 2 | 3 | const wrapAsync = 4 | (fn) => 5 | async (...args) => 6 | fn(...args); 7 | 8 | const { getItem, setItem, removeItem } = mapBind(wrapAsync, localStorage); 9 | 10 | export const createStorageService = () => { 11 | return { 12 | getItem, 13 | setItem, 14 | removeItem, 15 | }; 16 | }; 17 | -------------------------------------------------------------------------------- /apps/restaurant-cashier/products/image-uploader/images.service.js: -------------------------------------------------------------------------------- 1 | import { http } from '../../utils/http.js'; 2 | 3 | export const createImagesService = () => { 4 | return { 5 | async uploadImage({ file }) { 6 | return await http('images', { 7 | method: 'POST', 8 | body: file, 9 | }); 10 | }, 11 | }; 12 | }; 13 | 14 | export const imagesService = createImagesService(); 15 | -------------------------------------------------------------------------------- /apps/restaurant-cashier/utils/dom.js: -------------------------------------------------------------------------------- 1 | import { bind } from './objects.js'; 2 | const { 3 | createElement, 4 | querySelector, 5 | createRange, 6 | createTextNode, 7 | createTreeWalker, 8 | } = bind(document); 9 | const { matchMedia } = bind(window); 10 | 11 | export { 12 | createElement, 13 | matchMedia, 14 | querySelector, 15 | createRange, 16 | createTextNode, 17 | createTreeWalker, 18 | }; 19 | -------------------------------------------------------------------------------- /apps/todomvc/readme.md: -------------------------------------------------------------------------------- 1 | # todomvc example 2 | 3 | The famous todo app. 4 | 5 | From a technical point of view, it is built with typescript to show how you can integrate your components. 6 | There are various component patterns (even though they may not be the more appropriate to the situation): 7 | * declarative views 8 | * connected services 9 | * with shadow dom 10 | * extending built-in elements 11 | * etc 12 | -------------------------------------------------------------------------------- /apps/todomvc/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": "", 3 | "type": "module", 4 | "scripts": { 5 | "dev": "vite" 6 | }, 7 | "author": "", 8 | "license": "ISC", 9 | "devDependencies": { 10 | "typescript": "^5.2.2" 11 | }, 12 | "dependencies": { 13 | "@cofn/controllers": "workspace:*", 14 | "@cofn/core": "workspace:*", 15 | "@cofn/view": "workspace:*", 16 | "bootstrap-icons": "^1.11.2" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /apps/restaurant-cashier/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /apps/restaurant-cashier/utils/functions.js: -------------------------------------------------------------------------------- 1 | export const compose = (fns) => (args) => 2 | fns.reduceRight((y, fn) => fn(y), args); 3 | 4 | export const identity = (x) => x; 5 | 6 | export const noop = () => {}; 7 | 8 | export const wait = (time) => 9 | new Promise((resolve) => setTimeout(resolve, time)); 10 | 11 | export const curry2 = (fn) => (a, b) => { 12 | if (b === undefined) { 13 | return (b) => fn(a, b); 14 | } 15 | return fn(a, b); 16 | }; 17 | -------------------------------------------------------------------------------- /apps/restaurant-cashier/router/utils.js: -------------------------------------------------------------------------------- 1 | import { noop } from '../utils/functions.js'; 2 | 3 | export function composeStack(fns = []) { 4 | const [first, ...rest] = fns; 5 | 6 | if (first === void 0) { 7 | return noop; 8 | } 9 | 10 | const next = composeStack(rest); 11 | 12 | return async (ctx) => first(ctx, () => next(ctx)); 13 | } 14 | 15 | export function createContext({ state, router, routeDef }) { 16 | return { state, router, routeDef }; 17 | } 18 | -------------------------------------------------------------------------------- /apps/open-library/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "open-library", 3 | "version": "1.0.0", 4 | "description": "Use the [open library[(https://openlibrary.org)] API to find information about a book.", 5 | "main": "index.js", 6 | "scripts": { 7 | "dev": "vite" 8 | }, 9 | "dependencies": { 10 | "@cofn/controllers": "workspace:*", 11 | "@cofn/core": "workspace:*" 12 | }, 13 | "prettier": { 14 | "singleQuote": true 15 | }, 16 | "author": "", 17 | "license": "ISC" 18 | } 19 | -------------------------------------------------------------------------------- /apps/restaurant-cashier/utils/events.service.js: -------------------------------------------------------------------------------- 1 | export const createEventEmitter = () => { 2 | const service = new EventTarget(); 3 | return { 4 | on(...args) { 5 | service.addEventListener(...args); 6 | }, 7 | off(...args) { 8 | service.removeEventListener(...args); 9 | }, 10 | emit(event) { 11 | service.dispatchEvent( 12 | new CustomEvent(event.type, { 13 | detail: event.detail, 14 | }), 15 | ); 16 | }, 17 | }; 18 | }; 19 | -------------------------------------------------------------------------------- /apps/restaurant-cashier/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": "", 3 | "type": "module", 4 | "scripts": { 5 | "dev": "vite", 6 | "build": "vite build" 7 | }, 8 | "author": "", 9 | "license": "ISC", 10 | "devDependencies": { 11 | "nanoid": "^5.0.4" 12 | }, 13 | "dependencies": { 14 | "@cofn/controllers": "workspace:*", 15 | "@cofn/core": "workspace:*", 16 | "@cofn/view": "workspace:*", 17 | "barbapapa": "^0.1.0", 18 | "bootstrap-icons": "^1.11.3" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "prettier": { 3 | "singleQuote": true 4 | }, 5 | "keywords": [], 6 | "scripts": { 7 | "test": "pnpm test --recursive --if-present --stream", 8 | "build": "pnpm --filter @cofn/* --stream build", 9 | "size": "pnpm --filter @cofn/* --stream size" 10 | }, 11 | "author": "", 12 | "license": "ISC", 13 | "devDependencies": { 14 | "@rollup/plugin-terser": "^0.4.4", 15 | "playwright": "^1.57.0", 16 | "prettier": "^3.7.3", 17 | "rollup": "^4.53.3", 18 | "vite": "^7.2.4" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/di/src/injector.js: -------------------------------------------------------------------------------- 1 | export const factorify = (factoryLike) => 2 | typeof factoryLike === 'function' ? factoryLike : () => factoryLike; 3 | export const createInjector = ({ services }) => { 4 | const proxy = new Proxy(services, { 5 | get(target, prop, receiver) { 6 | if (!Reflect.has(services, prop)) { 7 | throw new Error(`could not resolve injectable "${prop}"`); 8 | } 9 | const factory = factorify(services[prop]); 10 | return factory(proxy); 11 | }, 12 | }); 13 | 14 | return proxy; 15 | }; 16 | -------------------------------------------------------------------------------- /apps/restaurant-cashier/readme.md: -------------------------------------------------------------------------------- 1 | # Restaurant cashier 2 | 3 | A _large_ single page app to emulate the user interface a agent could have to operate in a food shop. 4 | 5 | It solves most of the problematics you can get while building a single page app: 6 | * client side routing and page transition when supported 7 | * full keyboard accessibility 8 | * notifications 9 | * backend sync with optimistic updates 10 | * form and custom validation 11 | * user preferences management (theme, animation) 12 | * advanced components (select list, file upload with drag and drop) 13 | -------------------------------------------------------------------------------- /apps/todomvc/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "ESNext", 5 | "lib": ["ES2020", "DOM"], 6 | "skipLibCheck": true, 7 | "allowJs": true, 8 | /* Bundler mode */ 9 | "moduleResolution": "bundler", 10 | "allowImportingTsExtensions": true, 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "noEmit": true, 14 | "noImplicitAny": false, 15 | /* Linting */ 16 | "strict": true, 17 | "noUnusedLocals": true, 18 | "noUnusedParameters": true, 19 | "noFallthroughCasesInSwitch": true 20 | } 21 | } -------------------------------------------------------------------------------- /apps/restaurant-cashier/utils/objects.js: -------------------------------------------------------------------------------- 1 | import { curry2, identity } from './functions.js'; 2 | export const mapBind = curry2( 3 | (mapFn, target) => 4 | new Proxy(target, { 5 | get(target, prop) { 6 | return mapFn(Reflect.get(target, prop).bind(target)); 7 | }, 8 | }), 9 | ); 10 | 11 | export const mapValues = curry2((mapFn, target) => 12 | Object.fromEntries( 13 | Object.entries(target).map(([key, value]) => [key, mapFn(value)]), 14 | ), 15 | ); 16 | 17 | export const keyBy = curry2((keyFn, array) => 18 | Object.fromEntries(array.map((item) => [keyFn(item), item])), 19 | ); 20 | 21 | export const bind = mapBind(identity); 22 | -------------------------------------------------------------------------------- /apps/restaurant-cashier/utils/http.js: -------------------------------------------------------------------------------- 1 | import { APIRootURL } from '../config.js'; 2 | export const http = async (path, options = {}) => { 3 | const { query, ...rest } = options; 4 | const url = new URL(path, APIRootURL); 5 | if (query) { 6 | url.search = new URLSearchParams(query).toString(); 7 | } 8 | const result = await fetch(url, rest); 9 | 10 | if (!result.ok) { 11 | throw new Error('http error'); 12 | } 13 | 14 | const { headers } = result; 15 | 16 | const contentTypeHeader = headers.get('Content-Type'); 17 | 18 | if (contentTypeHeader?.includes('application/json')) { 19 | return result.json(); 20 | } 21 | 22 | return result; 23 | }; 24 | -------------------------------------------------------------------------------- /packages/core/src/define.js: -------------------------------------------------------------------------------- 1 | import { component } from './component.js'; 2 | 3 | export const define = (tag, coroutine, opts = {}) => 4 | customElements.define( 5 | tag, 6 | component(coroutine, { 7 | observedAttributes: opts.observedAttributes, 8 | Klass: classElementMap[opts?.extends] ?? HTMLElement, 9 | ...opts, 10 | }), 11 | opts, 12 | ); 13 | 14 | const classElementMap = { 15 | label: HTMLLabelElement, 16 | button: HTMLButtonElement, 17 | form: HTMLFormElement, 18 | li: HTMLLIElement, 19 | ul: HTMLUListElement, 20 | input: HTMLInputElement, 21 | p: HTMLParagraphElement, 22 | a: HTMLAnchorElement, 23 | body: HTMLBodyElement, 24 | }; 25 | -------------------------------------------------------------------------------- /apps/restaurant-cashier/products/product-list.model.js: -------------------------------------------------------------------------------- 1 | export const fromForm = (form) => { 2 | const formData = new FormData(form); 3 | return { 4 | sku: formData.get('sku'), 5 | price: { 6 | amountInCents: Number(formData.get('price')) * 100, 7 | currency: '$', 8 | }, 9 | title: formData.get('title'), 10 | ...(formData.get('description') !== '' 11 | ? { description: formData.get('description') } 12 | : {}), 13 | ...(formData.get('image') !== '' 14 | ? { 15 | image: { url: formData.get('image') }, 16 | } 17 | : {}), 18 | }; 19 | }; 20 | 21 | export const productSkus = ({ products }) => products.map(({ sku }) => sku); 22 | -------------------------------------------------------------------------------- /apps/restaurant-cashier/theme/reset.css: -------------------------------------------------------------------------------- 1 | *, 2 | *::before, 3 | *::after { 4 | box-sizing: border-box; 5 | } 6 | 7 | * { 8 | margin: 0; 9 | } 10 | 11 | body { 12 | line-height: 1.4; 13 | margin: unset; 14 | -webkit-font-smoothing: antialiased; 15 | } 16 | 17 | button, 18 | input, 19 | textarea, 20 | select { 21 | font: inherit; 22 | } 23 | 24 | p, h1, h2, h3, h4, h5, h6 { 25 | overflow-wrap: break-word; 26 | } 27 | 28 | img, 29 | picture, 30 | svg, 31 | canvas { 32 | display: block; 33 | max-inline-size: 100%; 34 | block-size: auto; 35 | } 36 | -------------------------------------------------------------------------------- /apps/restaurant-cashier/products/product-list.controller.js: -------------------------------------------------------------------------------- 1 | export const createProductListController = 2 | ({ productListService }) => 3 | (comp) => 4 | function* ({ $signal, $host, ...rest }) { 5 | const { render } = $host; 6 | $host.render = (args = {}) => { 7 | render({ 8 | ...args, 9 | ...productListService.getState(), 10 | }); 11 | }; 12 | 13 | productListService.on( 14 | 'product-list-changed', 15 | () => { 16 | $host.render(); 17 | }, 18 | { 19 | signal: $signal, 20 | }, 21 | ); 22 | 23 | yield* comp({ $signal, $host, productListService, ...rest }); 24 | }; 25 | -------------------------------------------------------------------------------- /apps/open-library/view.js: -------------------------------------------------------------------------------- 1 | export const getElements = 2 | (selectors = []) => 3 | (element) => 4 | Object.fromEntries( 5 | selectors.map((selector) => [selector, element.querySelector(selector)]), 6 | ); 7 | 8 | export const html = (parts, ...values) => { 9 | const [firstPart, ...rest] = parts; 10 | return ( 11 | firstPart + rest.map((part, index) => escape(values[index]) + part).join('') 12 | ); 13 | }; 14 | 15 | const escape = (html) => 16 | String(html).replace( 17 | /[&<>"]/g, 18 | (match) => 19 | ({ 20 | '&': '&', 21 | '<': '<', 22 | '>': '>', 23 | '"': '"', 24 | "'": ''', 25 | })[match], 26 | ); 27 | -------------------------------------------------------------------------------- /packages/test-lib/src/client/test.js: -------------------------------------------------------------------------------- 1 | import { createHarness } from 'zora/es'; 2 | const createReporter = (messageStream) => { 3 | return new ReadableStream({ 4 | async pull(controller) { 5 | const { done, value } = await messageStream.next(); 6 | if (done) { 7 | controller.close(); 8 | return; 9 | } 10 | return controller.enqueue(value); 11 | }, 12 | }); 13 | }; 14 | 15 | const { 16 | test, 17 | skip, 18 | only, 19 | report: _report, 20 | } = createHarness({ 21 | onlyMode: new URL(window.location).searchParams.get('only') === 'true', 22 | }); 23 | const report = () => _report({ reporter: createReporter }); 24 | 25 | export { test, skip, only, report }; 26 | -------------------------------------------------------------------------------- /packages/test-lib/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@cofn/test-lib", 3 | "version": "1.0.0", 4 | "description": "", 5 | "type": "module", 6 | "module": "./src/client/index.js", 7 | "style": "./src/client/theme.css", 8 | "scripts": {}, 9 | "exports": { 10 | "./package.json": "./package.json", 11 | "./client": { 12 | "import": "./src/client/index.js" 13 | }, 14 | "./client/theme.css": { 15 | "import": "./src/client/theme.css", 16 | "require": "./src/client/theme.css" 17 | }, 18 | "./vite": { 19 | "import": "./src/vite/index.js" 20 | } 21 | }, 22 | "author": "Laurent RENARD", 23 | "dependencies": { 24 | "zora": "^5.2.0", 25 | "zora-reporters": "^1.4.0" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /apps/todomvc/utils.ts: -------------------------------------------------------------------------------- 1 | export function compose( 2 | fns: [ 3 | func2: (input: FirstArg) => SecondArg, 4 | func: (input: Input) => FirstArg, 5 | ], 6 | ): (input: Input) => SecondArg { 7 | // return (arg) => fns.reduceRight((y, fn) => fn(y), arg); 8 | const [fn1, fn2] = fns; 9 | return (arg) => { 10 | return fn1(fn2(arg)); 11 | }; 12 | } 13 | export const not = 14 | (fn: (args: K) => boolean) => 15 | (arg: K) => 16 | !fn(arg); 17 | export const mapValues = 18 | (mapFn: (item: K) => T) => 19 | >(obj: Obj): Record => 20 | Object.fromEntries( 21 | Object.entries(obj).map(([key, value]) => [key, mapFn(value)]), 22 | ) as any; 23 | -------------------------------------------------------------------------------- /apps/restaurant-cashier/cart/cart.controller.js: -------------------------------------------------------------------------------- 1 | import { cartEvents, cartService } from './cart.service.js'; 2 | 3 | export const createCartController = 4 | ({ cartService }) => 5 | (comp) => 6 | function* ({ $signal, $host, ...rest }) { 7 | const { render } = $host; 8 | $host.render = (args = {}) => 9 | render({ 10 | ...args, 11 | ...cartService.getState(), 12 | }); 13 | 14 | cartService.on(cartEvents.CART_CHANGED, () => $host.render(), { 15 | signal: $signal, 16 | }); 17 | 18 | yield* comp({ 19 | $host, 20 | $signal, 21 | cartService, 22 | ...rest, 23 | }); 24 | }; 25 | 26 | export const withCartController = createCartController({ cartService }); 27 | -------------------------------------------------------------------------------- /packages/di/test/test-suite.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Test suite for package di 6 | 9 | 10 | 11 |
12 |

Test reporting

13 |
14 |
15 |
16 |
17 | 18 | 27 | 28 | -------------------------------------------------------------------------------- /apps/todomvc/components/ui-icon.component.js: -------------------------------------------------------------------------------- 1 | const template = document.createElement('template'); 2 | template.innerHTML = ` 3 | 13 | 14 | 15 | 16 | `; 17 | 18 | const spriteURL = `/components/icons.svg`; 19 | 20 | export const UIIcon = function* ({ $host, $root }) { 21 | $root.replaceChildren(template.content.cloneNode(true)); 22 | $root.querySelector('svg').setAttribute('aria-hidden', 'true'); 23 | while (true) { 24 | const iconName = $host.getAttribute('name'); 25 | $root 26 | .getElementById('use') 27 | .setAttribute('xlink:href', `${spriteURL}#${iconName}`); 28 | yield; 29 | } 30 | }; 31 | -------------------------------------------------------------------------------- /packages/controllers/test/test-suite.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Test suite for package controllers 6 | 9 | 10 | 11 |
12 |

Test reporting

13 |
14 |
15 |
16 |
17 | 18 | 27 | 28 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | defaults: 12 | run: 13 | shell: bash 14 | 15 | jobs: 16 | all: 17 | name: Test 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v3 21 | - uses: actions/setup-node@v3 22 | with: 23 | node-version: 20 24 | - uses: pnpm/action-setup@v2 25 | name: Install pnpm 26 | with: 27 | version: 8 28 | run_install: true 29 | - name: Build 30 | run: pnpm --filter @cofn/* --stream build 31 | - name: Install browsers 32 | run: pnpm exec playwright install --with-deps 33 | - name: Test 34 | run: pnpm t --recursive --stream --if-present 35 | 36 | 37 | -------------------------------------------------------------------------------- /apps/restaurant-cashier/users/preferences.controller.js: -------------------------------------------------------------------------------- 1 | import { preferencesEvents } from './preferences.service.js'; 2 | 3 | export const createPreferencesController = 4 | ({ preferencesService }) => 5 | (comp) => 6 | function* ({ $signal, $host, ...rest }) { 7 | const { render } = $host; 8 | $host.render = (args = {}) => { 9 | render({ 10 | ...args, 11 | ...preferencesService.getState(), 12 | }); 13 | }; 14 | 15 | preferencesService.on( 16 | preferencesEvents.PREFERENCES_CHANGED, 17 | () => { 18 | $host.render(); 19 | }, 20 | { 21 | signal: $signal, 22 | }, 23 | ); 24 | 25 | yield* comp({ 26 | $signal, 27 | $host, 28 | preferencesService, 29 | ...rest, 30 | }); 31 | }; 32 | -------------------------------------------------------------------------------- /packages/core/test/shadow-dom.test.js: -------------------------------------------------------------------------------- 1 | import { test } from '@cofn/test-lib/client'; 2 | import { define } from '../src/index.js'; 3 | import { nextTick } from './utils.js'; 4 | 5 | const debug = document.getElementById('debug'); 6 | function* component({ $root }) { 7 | while (true) { 8 | yield; 9 | $root.textContent = 'hello'; 10 | } 11 | } 12 | 13 | test('when shadow dom option is passed, $root becomes the shadow root', async ({ 14 | eq, 15 | }) => { 16 | define('shadow-dom', component, { 17 | shadow: { 18 | mode: 'open', 19 | }, 20 | }); 21 | 22 | define('light-dom', component); 23 | const elLight = document.createElement('light-dom'); 24 | const elShadow = document.createElement('shadow-dom'); 25 | debug.appendChild(elLight); 26 | await nextTick(); 27 | eq(elLight.textContent, 'hello'); 28 | eq(elShadow.textContent, ''); 29 | }); 30 | -------------------------------------------------------------------------------- /apps/restaurant-cashier/dashboard/top-items-chart.component.js: -------------------------------------------------------------------------------- 1 | export const TopItemsChart = 2 | ({ html }) => 3 | ({ items = [], summary = {} } = {}) => { 4 | return html`

Top items - counts

5 | ${items.map(({ label, value }) => { 11 | return html`${'bar-' + label}::${value}`; 14 | })}${items.length 15 | ? html`${items.map( 16 | ({ label }) => 17 | html`${'label-' + label}::${label}`, 20 | )}` 21 | : null}`; 23 | }; 24 | -------------------------------------------------------------------------------- /packages/core/src/index.d.ts: -------------------------------------------------------------------------------- 1 | type ComponentTag = `${string}-${string}`; 2 | 3 | export type ComponentDependencies = T & { 4 | $signal: AbortSignal; 5 | $host: HTMLElement & { 6 | render: >(input?: Update) => void; 7 | }; 8 | $root: ShadowRoot; 9 | }; 10 | 11 | export type ComponentRoutine< 12 | Dependencies = unknown, 13 | RenderingState = unknown, 14 | > = (dependencies: ComponentDependencies) => Generator< 15 | unknown, 16 | void, 17 | RenderingState & { 18 | attributes: Record; 19 | } 20 | >; 21 | 22 | export declare function define( 23 | tag: ComponentTag, 24 | component: ComponentRoutine, RenderingState>, 25 | options?: { 26 | shadow?: ShadowRootInit; 27 | extends?: string; 28 | observedAttributes?: string[]; 29 | }, 30 | ): void; 31 | -------------------------------------------------------------------------------- /packages/di/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@cofn/di", 3 | "version": "0.0.3", 4 | "description": "DOM as DI container", 5 | "type": "module", 6 | "main": "./dist/cofn-di.js", 7 | "exports": { 8 | "./package.json": "./package.json", 9 | ".": { 10 | "import": { 11 | "default": "./dist/cofn-di.js" 12 | } 13 | } 14 | }, 15 | "scripts": { 16 | "dev": "vite", 17 | "test": "node test/run-ci.js", 18 | "build": "mkdir -p dist && rollup src/index.js > dist/cofn-di.js", 19 | "size": "rollup -p @rollup/plugin-terser src/index.js | gzip | wc -c" 20 | }, 21 | "author": "Laurent RENARD", 22 | "devDependencies": { 23 | "@cofn/core": "workspace:^", 24 | "@cofn/test-lib": "workspace:*" 25 | }, 26 | "keywords": [ 27 | "di", 28 | "cofn", 29 | "web", 30 | "ui" 31 | ], 32 | "files": [ 33 | "dist" 34 | ], 35 | "license": "MIT" 36 | } 37 | -------------------------------------------------------------------------------- /packages/core/test/test-suite.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Test suite for package core 6 | 9 | 10 | 11 |
12 |

Test reporting

13 |
14 |
15 |
16 | 17 | 28 | 29 | -------------------------------------------------------------------------------- /apps/restaurant-cashier/components/ui-icon.component.js: -------------------------------------------------------------------------------- 1 | const template = document.createElement('template'); 2 | template.innerHTML = ` 3 | 13 | 14 | 15 | 16 | `; 17 | 18 | // todo add script to cherry pick the icons used 19 | const spriteURL = `/node_modules/bootstrap-icons/bootstrap-icons.svg`; 20 | 21 | export const UIIcon = function* ({ $host, $root }) { 22 | $root.replaceChildren(template.content.cloneNode(true)); 23 | $root.querySelector('svg').setAttribute('aria-hidden', 'true'); 24 | while (true) { 25 | const iconName = $host.getAttribute('name'); 26 | $root 27 | .getElementById('use') 28 | .setAttribute('xlink:href', `${spriteURL}#${iconName}`); 29 | yield; 30 | } 31 | }; 32 | -------------------------------------------------------------------------------- /apps/restaurant-cashier/dashboard/top-items-revenue-chart.component.js: -------------------------------------------------------------------------------- 1 | export const TopItemsRevenueChart = 2 | ({ html }) => 3 | ({ items = [], summary = {} } = {}) => { 4 | return html`

Top items - revenue

5 | ${items.map(({ label, value }) => { 11 | return html`${'bar-' + label}::${value / 100}$`; 14 | })}${items.length 15 | ? html`${items.map( 16 | ({ label }) => 17 | html`${'label-' + label}::${label}`, 20 | )}` 21 | : null}`; 23 | }; 24 | -------------------------------------------------------------------------------- /apps/open-library/search.service.js: -------------------------------------------------------------------------------- 1 | const APIRootURL = 'https://openlibrary.org/'; 2 | export const http = async (path, options = {}) => { 3 | const { query, ...rest } = options; 4 | const url = new URL(path, APIRootURL); 5 | if (query) { 6 | url.search = new URLSearchParams(query).toString(); 7 | } 8 | const result = await fetch(url, rest); 9 | 10 | if (!result.ok) { 11 | throw new Error('http error'); 12 | } 13 | 14 | return result.json(); 15 | }; 16 | 17 | export const createSearchService = () => { 18 | return { 19 | async search({ query: q }) { 20 | return http('search.json', { 21 | query: { 22 | q, 23 | limit: 10, 24 | fields: ['author_name', 'first_publish_year', 'title'], 25 | lang: 'en', 26 | language: 'eng', 27 | }, 28 | }); 29 | }, 30 | }; 31 | }; 32 | 33 | export const searchService = createSearchService(); 34 | -------------------------------------------------------------------------------- /apps/restaurant-cashier/index.js: -------------------------------------------------------------------------------- 1 | import { createApp } from './app.js'; 2 | import { defaultRouter } from './router/index.js'; 3 | 4 | const app = createApp({ router: defaultRouter }); 5 | 6 | app.start(); 7 | 8 | // service worker 9 | const registerServiceWorker = async () => { 10 | if ('serviceWorker' in navigator) { 11 | try { 12 | const registration = await navigator.serviceWorker.register( 13 | './service-worker.js', 14 | { type: 'module' }, 15 | ); 16 | if (registration.installing) { 17 | console.log('Service worker installing'); 18 | } else if (registration.waiting) { 19 | console.log('Service worker installed'); 20 | } else if (registration.active) { 21 | console.log('Service worker active'); 22 | } 23 | } catch (error) { 24 | console.error(`Registration failed with ${error}`); 25 | } 26 | } 27 | }; 28 | 29 | registerServiceWorker(); 30 | -------------------------------------------------------------------------------- /packages/view/test/test-suite.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Test suite for package view 6 | 9 | 10 | 11 |
12 |

Test reporting

13 |
14 |
15 |
16 |
17 | 18 | 30 | 31 | -------------------------------------------------------------------------------- /apps/restaurant-cashier/utils/notifications.service.js: -------------------------------------------------------------------------------- 1 | import { createEventEmitter } from './events.service.js'; 2 | 3 | export const notificationsEvents = { 4 | messagePublished: 'message-published', 5 | }; 6 | 7 | const levelList = ['info', 'warn', 'error']; 8 | export const createNotificationsService = () => { 9 | const emitter = createEventEmitter(); 10 | const publish = ({ payload, level }) => 11 | emitter.emit({ 12 | type: notificationsEvents.messagePublished, 13 | detail: { 14 | level, 15 | payload, 16 | }, 17 | }); 18 | 19 | const createPublisher = (level) => (payload) => 20 | publish({ 21 | payload, 22 | level, 23 | }); 24 | 25 | return Object.assign( 26 | emitter, 27 | Object.fromEntries( 28 | levelList.map((level) => [level, createPublisher(level)]), 29 | ), 30 | ); 31 | }; 32 | 33 | export const notificationsService = createNotificationsService(); 34 | -------------------------------------------------------------------------------- /packages/test-lib/src/vite/index.js: -------------------------------------------------------------------------------- 1 | import { ReadableStream } from 'node:stream/web'; 2 | import { createDiffReporter } from 'zora-reporters'; 3 | 4 | const createStream = ({ ws }) => { 5 | let listener; 6 | return new ReadableStream({ 7 | start(controller) { 8 | listener = (data, client) => { 9 | if (data.type === 'STREAM_ENDED') { 10 | controller.close(); 11 | ws.off('zora', listener); 12 | } else { 13 | controller.enqueue(data); 14 | } 15 | }; 16 | 17 | ws.on('zora', listener); 18 | }, 19 | }); 20 | }; 21 | export default () => ({ 22 | name: 'zora-dev', 23 | async configureServer(server) { 24 | const report = createDiffReporter(); 25 | server.ws.on('zora', async ({ type }) => { 26 | if (type === 'STREAM_STARTED') { 27 | const readableStream = createStream({ ws: server.ws }); 28 | await report(readableStream); 29 | } 30 | }); 31 | }, 32 | }); 33 | -------------------------------------------------------------------------------- /apps/restaurant-cashier/users/me.page.js: -------------------------------------------------------------------------------- 1 | import { createElement } from '../utils/dom.js'; 2 | import { createPreferencesController } from './preferences.controller.js'; 3 | import { withView } from '@cofn/view'; 4 | import { PreferencesComponent } from './preferences.component.js'; 5 | 6 | const template = createElement('template'); 7 | 8 | template.innerHTML = `

My account

9 |
10 | UI Preferences 11 | 12 |
13 | `; 14 | 15 | export const loadPage = async ({ define, preferencesService }) => { 16 | const withPreferencesController = createPreferencesController({ 17 | preferencesService, 18 | }); 19 | define( 20 | 'app-preferences', 21 | withPreferencesController(withView(PreferencesComponent)), 22 | ); 23 | 24 | return { 25 | title: 'Settings', 26 | content: template.content.cloneNode(true), 27 | }; 28 | }; 29 | -------------------------------------------------------------------------------- /apps/todomvc/todo-list.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | injectTodoService, 3 | TodoService, 4 | TodoServiceState, 5 | } from './todo.service.ts'; 6 | import { ComponentDependencies, ComponentRoutine } from '@cofn/core'; 7 | export const connectTodoService = ( 8 | comp: ComponentRoutine< 9 | { todoService: TodoService }, 10 | { state: TodoServiceState } 11 | >, 12 | ) => { 13 | return injectTodoService(connector); 14 | function* connector({ 15 | $signal, 16 | todoService, 17 | $host, 18 | ...deps 19 | }: ComponentDependencies<{ todoService: TodoService }>) { 20 | const { render: _render } = $host; 21 | 22 | $host.render = (args?) => 23 | _render({ 24 | ...(args ?? {}), 25 | state: todoService.getState(), 26 | }); 27 | 28 | todoService.addEventListener('state-changed', $host.render, { 29 | signal: $signal, 30 | }); 31 | 32 | yield* comp({ $signal, $host, todoService, ...deps }); 33 | } 34 | }; 35 | -------------------------------------------------------------------------------- /apps/restaurant-cashier/theme/layout.css: -------------------------------------------------------------------------------- 1 | .content-grid { 2 | --_narrow-max-width: var(--narrow-max-width, 60ch); 3 | --_padding-inline: var(--spacing-big, 1rem); 4 | --_wide-max-width: var(--max-width, 75ch); 5 | --_wide-size: calc((var(--_wide-max-width) - var(--_narrow-max-width)) / 2); 6 | 7 | display: grid; 8 | grid-template-columns: 9 | minmax(var(--_padding-inline), 1fr) [full-start] 1fr [wide-start] minmax(0, var(--_wide-size)) [narrow-start] min(100% - 2 * var(--_padding-inline), var(--_narrow-max-width)) [narrow-end] minmax(0, var(--_wide-size)) [wide-end] 1fr [full-end] minmax(var(--_padding-inline), 1fr); 10 | } 11 | 12 | .grid-full { 13 | grid-column: full; 14 | display: grid; 15 | grid-template-columns: subgrid; 16 | } 17 | 18 | .grid-wide, 19 | ui-page-outlet > * { 20 | grid-column: wide; 21 | } 22 | 23 | .grid-narrow { 24 | grid-column: narrow; 25 | } 26 | 27 | ui-page-outlet { 28 | display: contents; 29 | } 30 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # cofn 2 | 3 | A set of small libraries to build your UI framework [converting coroutines into web components](https://lorenzofox.dev/posts/component-as-infinite-loop/). 4 | 5 | * [core](./packages/core): define web components from a coroutine (666 bytes) 6 | * [view](./packages/view): use declarative template rather than imperative rendering logic (1645 bytes) 7 | * [controllers](./packages/controllers): manage state updates from a controller function (351 bytes) 8 | * [di](./packages/di): define provider elements an inject what they create into children elements (408 bytes) 9 | 10 | Sizes are in bytes (minified and gzipped) 11 | 12 | # examples 13 | 14 | There are a [list of example applications](./apps). From simple to complex single page apps: 15 | 16 | * [single page app](https://www.youtube.com/watch?v=clpY08fA0qs) 17 | * [todomvc](https://www.youtube.com/watch?v=h51et4N9g-Y) 18 | * [open library search](https://www.youtube.com/watch?v=51qb8Z_QWxw) 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /packages/view/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@cofn/view", 3 | "version": "0.0.1", 4 | "description": "", 5 | "type": "module", 6 | "types": "./dist/index.d.ts", 7 | "exports": { 8 | "./package.json": "./package.json", 9 | ".": { 10 | "import": { 11 | "types": "./dist/index.d.ts", 12 | "default": "./dist/cofn-view.js" 13 | } 14 | } 15 | }, 16 | "prettier": { 17 | "singleQuote": true 18 | }, 19 | "files": ["dist"], 20 | "scripts": { 21 | "dev": "vite", 22 | "test": "node test/run-ci.js", 23 | "build": "mkdir -p dist && rollup src/index.js > dist/cofn-view.js && cp src/index.d.ts dist", 24 | "size": "rollup -p @rollup/plugin-terser src/index.js | gzip | wc -c" 25 | }, 26 | "author": "Laurent RENARD", 27 | "peerDependencies": { 28 | "@cofn/core": "workspace:*" 29 | }, 30 | "devDependencies": { 31 | "@cofn/core": "workspace:*", 32 | "@cofn/test-lib": "workspace:*", 33 | "zora-reporters": "^1.4.0" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /apps/open-library/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Open library search example 6 | 7 | 8 | 9 | 10 | 11 |
12 |

Open library search

13 | 14 |
15 | 16 |
17 | 18 | 19 |
20 |
21 |
    22 |
    23 |
    24 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /packages/controllers/src/props.js: -------------------------------------------------------------------------------- 1 | export const withProps = (props) => (gen) => 2 | function* ({ $host, ...rest }) { 3 | const properties = {} || rest.properties; 4 | const { render } = $host; 5 | 6 | $host.render = (update = {}) => 7 | render({ 8 | properties: { 9 | ...properties, 10 | }, 11 | ...update, 12 | }); 13 | 14 | Object.defineProperties( 15 | $host, 16 | Object.fromEntries( 17 | props.map((propName) => { 18 | properties[propName] = $host[propName]; 19 | return [ 20 | propName, 21 | { 22 | enumerable: true, 23 | get() { 24 | return properties[propName]; 25 | }, 26 | set(value) { 27 | properties[propName] = value; 28 | $host.render(); 29 | }, 30 | }, 31 | ]; 32 | }), 33 | ), 34 | ); 35 | 36 | yield* gen({ $host, ...rest }); 37 | }; 38 | -------------------------------------------------------------------------------- /packages/controllers/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@cofn/controllers", 3 | "version": "0.0.3", 4 | "description": "A set of higher order function to help implement update logic", 5 | "type": "module", 6 | "main": "./dist/cofn-controllers.js", 7 | "types": "./dist/index.d.ts", 8 | "exports": { 9 | "./package.json": "./package.json", 10 | ".": { 11 | "import": { 12 | "types": "./dist/index.d.ts", 13 | "default": "./dist/cofn-controllers.js" 14 | } 15 | } 16 | }, 17 | "prettier": { 18 | "singleQuote": true 19 | }, 20 | "files": ["dist"], 21 | "scripts": { 22 | "dev": "vite", 23 | "test": "node test/run-ci.js", 24 | "build": "mkdir -p dist && rollup src/index.js > dist/cofn-controllers.js && cp src/index.d.ts dist", 25 | "size": "rollup -p @rollup/plugin-terser src/index.js | gzip | wc -c" 26 | }, 27 | "author": "Laurent RENARD", 28 | "devDependencies": { 29 | "@cofn/core": "workspace:*", 30 | "@cofn/test-lib": "workspace:*" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /apps/restaurant-cashier/cart/cart-product-item.component.js: -------------------------------------------------------------------------------- 1 | import { compose } from '../utils/functions.js'; 2 | import { withView } from '@cofn/view'; 3 | import { withProps } from '@cofn/controllers'; 4 | 5 | const compositionPipeline = compose([withProps(['product']), withView]); 6 | 7 | export const CartProductItem = compositionPipeline(({ html, $host }) => { 8 | return ({ properties: { product } }) => { 9 | if (product.image?.url) { 10 | $host.style.setProperty('background-image', `url(${product.image.url})`); 11 | } 12 | 13 | return html` 14 |
    ${product.title}
    15 | 18 | 25 | `; 26 | }; 27 | }); 28 | -------------------------------------------------------------------------------- /apps/todomvc/views/add-todo.view.ts: -------------------------------------------------------------------------------- 1 | import { TodoService } from '../todo.service.ts'; 2 | import { ViewFactory } from '@cofn/view'; 3 | 4 | export const AddTodoView: ViewFactory<{ todoService: TodoService }> = ({ 5 | html, 6 | todoService, 7 | }) => { 8 | const handleSubmit = (ev) => { 9 | ev.preventDefault(); 10 | const { target: form } = ev; 11 | const content = form.elements.namedItem('new-content').value; 12 | todoService.addTodo({ content }); 13 | form.reset(); 14 | }; 15 | 16 | return () => 17 | html`
    18 | 19 |
    20 | 28 | 29 |
    30 |
    `; 31 | }; 32 | -------------------------------------------------------------------------------- /packages/core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@cofn/core", 3 | "version": "0.0.2", 4 | "description": "small library to turn generator function into a web component", 5 | "type": "module", 6 | "types": "./dist/index.d.ts", 7 | "main": "./dist/cofn-core.js", 8 | "exports": { 9 | "./package.json": "./package.json", 10 | ".": { 11 | "import": { 12 | "types": "./dist/index.d.ts", 13 | "default": "./dist/cofn-core.js" 14 | } 15 | } 16 | }, 17 | "prettier": { 18 | "singleQuote": true 19 | }, 20 | "files": ["dist"], 21 | "scripts": { 22 | "dev": "vite", 23 | "test": "node test/run-ci.js", 24 | "build": "mkdir -p dist && rollup src/index.js > dist/cofn-core.js && cp src/index.d.ts dist", 25 | "size": "rollup -p @rollup/plugin-terser src/index.js | gzip | wc -c" 26 | }, 27 | "author": "Laurent RENARD", 28 | "keywords": ["webcomponent", "web component", "ui", "generator", "coroutine"], 29 | "devDependencies": { 30 | "@cofn/test-lib": "workspace:*" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /apps/restaurant-cashier/products/list/product-list.page.js: -------------------------------------------------------------------------------- 1 | import { compose } from '../../utils/functions.js'; 2 | import { createProductListController } from '../product-list.controller.js'; 3 | import { productListService } from '../product-list.service.js'; 4 | import { withView } from '@cofn/view'; 5 | import { ProductList } from './product-list.component.js'; 6 | import { ProductListItem } from './product-list-item.component.js'; 7 | import { createElement } from '../../utils/dom.js'; 8 | 9 | const connectedProductListView = compose([ 10 | createProductListController({ 11 | productListService, 12 | }), 13 | withView, 14 | ]); 15 | export const loadPage = async ({ define, state }) => { 16 | define('app-product-list-item', ProductListItem); 17 | define('app-product-list', connectedProductListView(ProductList)); 18 | const element = createElement('app-product-list'); 19 | if (state?.sku) { 20 | element.setAttribute('target-sku', state.sku); 21 | } 22 | return { 23 | title: 'Products', 24 | content: element, 25 | }; 26 | }; 27 | -------------------------------------------------------------------------------- /apps/open-library/book-list.controller.js: -------------------------------------------------------------------------------- 1 | import { withController } from '@cofn/controllers'; 2 | import { searchService } from './search.service.js'; 3 | 4 | const toViewModel = ({ 5 | author_name: authorName, 6 | first_publish_year: firstPublishYear, 7 | title, 8 | }) => ({ 9 | authorName: authorName[0], 10 | firstPublishYear, 11 | title, 12 | }); 13 | 14 | const createBookListController = ({ state }) => { 15 | // initial state 16 | state.isLoading = false; 17 | state.books = []; 18 | state.error = undefined; 19 | 20 | return { 21 | async search({ query }) { 22 | try { 23 | state.isLoading = true; 24 | state.books = []; 25 | state.error = undefined; 26 | const { docs } = await searchService.search({ query }); 27 | state.books = docs.map(toViewModel); 28 | } catch (err) { 29 | state.error = err; 30 | } finally { 31 | state.isLoading = false; 32 | } 33 | }, 34 | }; 35 | }; 36 | 37 | export const withBookListController = withController(createBookListController); 38 | -------------------------------------------------------------------------------- /packages/controllers/src/index.d.ts: -------------------------------------------------------------------------------- 1 | import { ComponentRoutine, ComponentDependencies } from '@cofn/core'; 2 | 3 | type ControllerFn = ( 4 | controllerDeps: { 5 | state: State; 6 | } & ComponentDependencies, 7 | ) => Controller; 8 | 9 | export declare function withController< 10 | State, 11 | Controller, 12 | Dependencies = unknown, 13 | >( 14 | controllerFn: ControllerFn, 15 | ): ( 16 | view: ComponentRoutine< 17 | { 18 | controller: Controller & { getState: () => State }; 19 | } & Dependencies, 20 | { state: State } 21 | >, 22 | ) => ComponentRoutine; 23 | 24 | export declare function withProps>( 25 | props: (keyof Properties)[], 26 | ): ( 27 | view: ComponentRoutine< 28 | Dependencies, 29 | { 30 | properties: Properties; 31 | } 32 | >, 33 | ) => ComponentRoutine< 34 | Dependencies, 35 | { 36 | properties: Properties; 37 | } 38 | >; 39 | -------------------------------------------------------------------------------- /packages/view/src/index.d.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ComponentDependencies as _ComponentDependencies, 3 | ComponentRoutine, 4 | } from '@cofn/core'; 5 | 6 | type TemplateRecord = { 7 | content: DocumentFragment; 8 | }; 9 | 10 | type TemplateTagFunction = ( 11 | templateParts: TemplateStringsArray, 12 | ...values: unknown[] 13 | ) => TemplateRecord; 14 | 15 | export type ComponentDependencies = _ComponentDependencies< 16 | T & { 17 | html: TemplateTagFunction; 18 | } 19 | >; 20 | 21 | export declare function createHTML({ 22 | $signal, 23 | }: { 24 | $signal: AbortSignal; 25 | }): TemplateTagFunction; 26 | 27 | type ViewFunction = ( 28 | state: State & { attributes: Record }, 29 | ) => TemplateRecord; 30 | 31 | export type ViewFactory = ( 32 | dependencies: ComponentDependencies, 33 | ) => ViewFunction; 34 | 35 | export declare function withView( 36 | viewFactory: ViewFactory, 37 | ): ComponentRoutine; 38 | -------------------------------------------------------------------------------- /apps/restaurant-cashier/router/page-link.component.js: -------------------------------------------------------------------------------- 1 | import { navigationEvents } from './router.js'; 2 | 3 | export const connectToRouter = (component) => 4 | function* ({ router, $host, $signal, ...rest }) { 5 | const linkHref = $host.getAttribute('href'); 6 | router.on( 7 | navigationEvents.ROUTE_CHANGE_SUCCEEDED, 8 | ({ detail }) => { 9 | const requestedPathname = new URL(detail.requestedURL).pathname; 10 | const isCurrent = requestedPathname.includes(linkHref); 11 | $host.render({ isCurrent }); 12 | }, 13 | { signal: $signal }, 14 | ); 15 | 16 | $host.addEventListener('click', (ev) => { 17 | ev.preventDefault(); 18 | router.goTo(linkHref); 19 | }); 20 | 21 | yield* component({ router, $host, $signal, ...rest }); 22 | }; 23 | 24 | export const PageLink = connectToRouter(function* ({ $host }) { 25 | while (true) { 26 | const { isCurrent = false } = yield; 27 | $host.removeAttribute('aria-current'); 28 | if (isCurrent === true) { 29 | $host.setAttribute('aria-current', 'page'); 30 | } 31 | } 32 | }); 33 | -------------------------------------------------------------------------------- /packages/controllers/src/controller.js: -------------------------------------------------------------------------------- 1 | export const withController = (controllerFn) => (view) => 2 | function* (deps) { 3 | const state = deps.state || {}; 4 | const { $host } = deps; 5 | 6 | const ctrl = { 7 | getState() { 8 | return structuredClone(state); 9 | }, 10 | ...controllerFn({ 11 | ...deps, 12 | // inject a proxy on state so whenever a setter is called the view gets updated 13 | state: new Proxy(state, { 14 | set(obj, prop, value) { 15 | obj[prop] = value; 16 | // no need to render if the view is not connected 17 | if ($host.isConnected) { 18 | $host.render(); 19 | } 20 | return true; 21 | }, 22 | }), 23 | }), 24 | }; 25 | 26 | // overwrite render fn 27 | const { render } = $host; 28 | $host.render = (args = {}) => 29 | render({ 30 | ...args, 31 | state: ctrl.getState(), 32 | }); 33 | 34 | // inject controller in the view 35 | yield* view({ 36 | ...deps, 37 | controller: ctrl, 38 | }); 39 | }; 40 | -------------------------------------------------------------------------------- /apps/restaurant-cashier/theme/button.css: -------------------------------------------------------------------------------- 1 | a.button-like { 2 | text-decoration: none; 3 | color: currentColor; 4 | } 5 | 6 | button, 7 | .button-like, 8 | ::part(button) { 9 | --_padding: var(--padding, 0.4em); 10 | --_shadow-color: var(--shadow-color, currentColor); 11 | font-size: 0.9em; 12 | background: inherit; 13 | padding-inline: var(--_padding); 14 | padding-block: calc(var(--_padding) / 2.5); 15 | border: 1px solid currentColor; 16 | font-weight: 300; 17 | cursor: pointer; 18 | box-shadow: 0 0 1px 0 var(--_shadow-color), 19 | 0 0 3px 0 var(--_shadow-color); 20 | font-family: inherit; 21 | color: inherit; 22 | border-radius: var(--border-radius); 23 | outline-offset: 4px; 24 | 25 | &.action { 26 | background: var(--action-color); 27 | color: var(--action-color-contrast); 28 | border-color: transparent; 29 | } 30 | 31 | &:not(:active):hover { 32 | box-shadow: 0 0 2px 0 var(--_shadow-color), 33 | 0 0 6px 0 var(--_shadow-color); 34 | 35 | } 36 | 37 | &:active { 38 | box-shadow: 0 0 2px 1px var(--_shadow-color); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /packages/test-lib/src/client/html-reporter/html-repoter.js: -------------------------------------------------------------------------------- 1 | import { Diagnostic } from './diagnostic.component.js'; 2 | import { TestResult } from './test-result.component.js'; 3 | 4 | customElements.define('zora-diagnostic', Diagnostic); 5 | customElements.define('zora-test-result', TestResult); 6 | 7 | export const createHTMLReporter = ({ element }) => { 8 | const rootEl = element || document.querySelector('body'); 9 | let currentTestEl; 10 | return new WritableStream({ 11 | start() { 12 | rootEl.replaceChildren(); 13 | }, 14 | write(message) { 15 | const { type, data } = message; 16 | switch (type) { 17 | case 'TEST_START': { 18 | const el = (currentTestEl = 19 | document.createElement('zora-test-result')); 20 | rootEl.appendChild(el); 21 | el.description = data.description; 22 | break; 23 | } 24 | case 'ASSERTION': { 25 | currentTestEl.addAssertion(data); 26 | break; 27 | } 28 | case 'TEST_END': { 29 | currentTestEl.endsTest(data); 30 | break; 31 | } 32 | } 33 | }, 34 | }); 35 | }; 36 | -------------------------------------------------------------------------------- /apps/restaurant-cashier/components/ui-label.component.js: -------------------------------------------------------------------------------- 1 | import { createRange, createTextNode } from '../utils/dom.js'; 2 | 3 | const errorTemplate = document.createElement('template'); 4 | errorTemplate.innerHTML = ``; 5 | 6 | export const UiLabelComponent = function* ({ $host }) { 7 | const { control } = $host; 8 | $host.append(errorTemplate.content.cloneNode(true)); 9 | const textRange = createRange(); 10 | const inputError = $host.querySelector('.input-error'); 11 | const iconEl = $host.querySelector('.input-error > ui-icon'); 12 | textRange.setStartAfter(iconEl); 13 | textRange.setEndAfter(iconEl); 14 | control.addEventListener('invalid', (ev) => { 15 | if (textRange.collapsed) { 16 | textRange.insertNode(createTextNode(control.validationMessage)); 17 | inputError.classList.toggle('active'); 18 | control.addEventListener( 19 | 'input', 20 | () => { 21 | inputError.classList.toggle('active'); 22 | control.setCustomValidity(''); 23 | textRange.deleteContents(); 24 | }, 25 | { 26 | once: true, 27 | }, 28 | ); 29 | } 30 | }); 31 | }; 32 | -------------------------------------------------------------------------------- /apps/todomvc/app.ts: -------------------------------------------------------------------------------- 1 | import { define } from '@cofn/core'; 2 | import { withView } from '@cofn/view'; 3 | import { TodoItemView } from './views/todo-item.view.js'; 4 | import { connectTodoService } from './todo-list.controller.js'; 5 | import { TodoListView } from './views/todo-list.view.js'; 6 | import { AddTodoView } from './views/add-todo.view.ts'; 7 | import { 8 | injectTodoService, 9 | TodoService, 10 | TodoServiceState, 11 | } from './todo.service.ts'; 12 | import { todoListControlsView } from './views/todo-list-controls.view.js'; 13 | import { compose } from './utils.js'; 14 | import { UIIcon } from './components/ui-icon.component'; 15 | 16 | const connectWithView = compose([ 17 | connectTodoService, 18 | withView<{ todoService: TodoService }, { state: TodoServiceState }>, 19 | ]); 20 | 21 | define('ui-icon', UIIcon, { 22 | observedAttributes: ['name'], 23 | shadow: { 24 | mode: 'open', 25 | }, 26 | }); 27 | define('app-todo', withView(TodoItemView), { 28 | shadow: { mode: 'open', delegatesFocus: true }, 29 | observedAttributes: ['completed'], 30 | }); 31 | define('app-todo-list', connectWithView(TodoListView)); 32 | define('app-add-todo', injectTodoService(withView(AddTodoView))); 33 | define('app-controls', connectWithView(todoListControlsView), { 34 | extends: 'header', 35 | }); 36 | -------------------------------------------------------------------------------- /apps/restaurant-cashier/utils/animations.service.js: -------------------------------------------------------------------------------- 1 | import { wait } from './functions.js'; 2 | import { 3 | motionSettings, 4 | preferencesEvents, 5 | } from '../users/preferences.service.js'; 6 | 7 | const removeAnimation = [ 8 | { 9 | opacity: 1, 10 | transform: 'scaleX(1) scaleY(1)', 11 | }, 12 | { 13 | opacity: 0, 14 | transform: 'scaleX(0) scaleY(0)', 15 | }, 16 | ]; 17 | 18 | const animationConfiguration = { 19 | duration: 200, 20 | iterations: 1, 21 | fill: 'forwards', 22 | }; 23 | 24 | export const createAnimationsService = ({ preferencesService }) => { 25 | preferencesService.on(preferencesEvents.PREFERENCES_CHANGED, () => { 26 | const { 27 | motion: { value }, 28 | } = preferencesService.getState(); 29 | animationConfiguration.duration = 30 | value === motionSettings.reduced ? 0 : 200; 31 | }); 32 | 33 | return { 34 | async removeElement(el) { 35 | return await Promise.all([ 36 | animate(el, removeAnimation, animationConfiguration), 37 | wait(animationConfiguration.duration * 1.1), 38 | ]); 39 | }, 40 | }; 41 | }; 42 | const animate = (el, ...rest) => 43 | new Promise((resolve) => { 44 | const animation = el.animate(...rest); 45 | animation.addEventListener('finish', resolve, { once: true }); 46 | }); 47 | -------------------------------------------------------------------------------- /packages/core/test/abort-signal.test.js: -------------------------------------------------------------------------------- 1 | import { define } from '../src/index.js'; 2 | import { nextTick } from './utils.js'; 3 | import { test } from '@cofn/test-lib/client'; 4 | 5 | const debug = document.getElementById('debug'); 6 | define('abort-signal', function* ({ $signal, $host }) { 7 | $host.hasBeenRemoved = false; 8 | 9 | $signal.addEventListener('abort', () => { 10 | $host.hasBeenRemoved = true; 11 | }); 12 | 13 | while (true) { 14 | yield; 15 | $host.textContent = 'simple component'; 16 | } 17 | }); 18 | 19 | const withEl = (specFn) => 20 | function zora_spec_fn(assert) { 21 | const el = document.createElement('abort-signal'); 22 | debug.appendChild(el); 23 | return specFn({ ...assert, el }); 24 | }; 25 | 26 | test( 27 | 'when removed the abort signal is called', 28 | withEl(async ({ eq, el }) => { 29 | await nextTick(); 30 | 31 | eq(el.hasBeenRemoved, false); 32 | 33 | el.remove(); 34 | 35 | await nextTick(); 36 | 37 | eq(el.hasBeenRemoved, true); 38 | }), 39 | ); 40 | 41 | test( 42 | 'when moved around, the abort signal is not called', 43 | withEl(async ({ eq, el }) => { 44 | await nextTick(); 45 | 46 | eq(el.hasBeenRemoved, false); 47 | debug.prepend(el); 48 | 49 | await nextTick(); 50 | 51 | eq(el.hasBeenRemoved, false); 52 | }), 53 | ); 54 | -------------------------------------------------------------------------------- /apps/restaurant-cashier/dashboard/cart-count-chart.component.js: -------------------------------------------------------------------------------- 1 | const formatLabel = (label) => label.split('/').slice(0, 2).join('/'); 2 | 3 | export const CartCountChart = 4 | ({ html }) => 5 | ({ items = [], summary = {} } = {}) => { 6 | return html`

    Cart count

    7 |
    8 | ${summary.succeeded 9 | ? html`
    success: ${summary.succeeded}
    10 |
    fails: ${summary.failed}
    ` 11 | : null} 12 |
    13 | ${items.map(({ label, succeeded, failed }) => { 19 | return html`${'bar-' + label}:: 20 | 21 | ${succeeded} 24 | ${failed} 25 | `; 26 | })}${items.length 27 | ? html`${items.map( 28 | ({ label }) => 29 | html`${'label-' + label}::${formatLabel(label)}`, 32 | )}` 33 | : null}`; 35 | }; 36 | -------------------------------------------------------------------------------- /apps/todomvc/components/icons.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /apps/restaurant-cashier/dashboard/revenues-chart.component.js: -------------------------------------------------------------------------------- 1 | const model = ({ items = [], summary = {} } = {}) => { 2 | return { 3 | summary: { 4 | amount: Math.round(summary.amountInCents / 100), 5 | currency: summary.currency, 6 | }, 7 | items: items.map(({ value, label }) => ({ 8 | label, 9 | amount: Math.round(value / 100), 10 | })), 11 | }; 12 | }; 13 | const formatLabel = (label) => label.split('/').slice(0, 2).join('/'); 14 | 15 | export const RevenuesChart = 16 | ({ html }) => 17 | (data) => { 18 | const { items = [], summary = {} } = model(data); 19 | return html`

    Revenue

    20 | ${summary.amount ? summary.amount + summary.currency : ''} 21 | ${items.map(({ label, amount }, i) => { 26 | return html`${'bar-' + label}::${amount + '$'}`; 29 | })}${ 30 | items.length 31 | ? html`${items.map( 32 | ({ label }) => 33 | html`${'label-' + label}::${formatLabel(label)}`, 36 | )}` 37 | : null 38 | }`; 40 | }; 41 | -------------------------------------------------------------------------------- /apps/restaurant-cashier/cart/cart.page.js: -------------------------------------------------------------------------------- 1 | import { createElement } from '../utils/dom.js'; 2 | import { withView } from '@cofn/view'; 3 | import { Cart } from './cart.component.js'; 4 | import { CartProductList } from './cart-product-list.component.js'; 5 | import { createProductListController } from '../products/product-list.controller.js'; 6 | import { productListService } from '../products/product-list.service.js'; 7 | import { CartProductItem } from './cart-product-item.component.js'; 8 | import { 9 | UIListbox, 10 | UIListboxOption, 11 | } from '../components/ui-listbox.component.js'; 12 | import { withCartController } from './cart.controller.js'; 13 | 14 | const template = createElement('template'); 15 | template.innerHTML = ` 16 |

    Cart

    17 |
    18 | 19 | 20 |
    21 | `; 22 | 23 | export const loadPage = ({ define }) => { 24 | define('ui-listbox-option', UIListboxOption, { 25 | extends: 'li', 26 | }); 27 | define('ui-listbox', UIListbox, { 28 | extends: 'ul', 29 | }); 30 | define('app-cart', withCartController(withView(Cart))); 31 | define('app-cart-product-item', CartProductItem); 32 | define( 33 | 'app-cart-product-list', 34 | withCartController(withView(CartProductList)), 35 | ); 36 | return { 37 | title: 'Current cart', 38 | content: template.content.cloneNode(true), 39 | }; 40 | }; 41 | -------------------------------------------------------------------------------- /apps/restaurant-cashier/cart/cart-product-list.component.js: -------------------------------------------------------------------------------- 1 | import { compose } from '../utils/functions.js'; 2 | 3 | export const CartProductList = ({ html, cartService }) => { 4 | const handleSelectionChange = compose([ 5 | cartService.setItemQuantity, 6 | cartItemFromOption, 7 | ({ detail }) => detail.option, 8 | ]); 9 | return ({ products: _products, currentCart }) => { 10 | const products = Object.values(_products); 11 | const cartProductSKUs = Object.keys(currentCart.items); 12 | return html`

    13 | Available products 14 |

    15 |
      22 | ${products.map( 23 | (product) => 24 | html`${product.sku}:: 25 |
    • 32 | 35 |
    • `, 36 | )} 37 |
    `; 38 | }; 39 | }; 40 | 41 | const cartItemFromOption = ({ value, selected }) => ({ 42 | sku: value, 43 | quantity: selected ? 1 : 0, 44 | }); 45 | -------------------------------------------------------------------------------- /apps/todomvc/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Todo MVC 6 | 7 | 8 | 9 | 10 | 11 |

    Todo list app

    12 |
    13 | 14 |
    15 | 16 |
    17 | 24 | 25 |
    26 |
    27 |
    28 |
    29 |

    Todo list

    30 |
    31 | 35 | 36 | Loading your todos... 37 | 38 |
    39 |
    40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /apps/restaurant-cashier/dashboard/dashboard.page.js: -------------------------------------------------------------------------------- 1 | import { createElement } from '../utils/dom.js'; 2 | import { withView } from '@cofn/view'; 3 | import { RevenuesChart } from './revenues-chart.component.js'; 4 | import { withChartData } from './dashboard.controller.js'; 5 | import { CartCountChart } from './cart-count-chart.component.js'; 6 | import { TopItemsChart } from './top-items-chart.component.js'; 7 | import { compose } from '../utils/functions.js'; 8 | import { TopItemsRevenueChart } from './top-items-revenue-chart.component.js'; 9 | import 'barbapapa'; 10 | 11 | const template = createElement('template'); 12 | template.innerHTML = `

    Weekly dashboard

    13 |
    14 | 15 | 16 | 17 | 18 |
    `; 19 | 20 | const chart = compose([withChartData, withView]); 21 | export const loadPage = ({ define }) => { 22 | define('app-revenues-chart', chart(RevenuesChart)); 23 | define('app-cart-count-chart', chart(CartCountChart)); 24 | define('app-top-items-chart', chart(TopItemsChart)); 25 | define('app-top-items-revenue-chart', chart(TopItemsRevenueChart)); 26 | 27 | return { 28 | title: 'Dashboard', 29 | content: template.content.cloneNode(true), 30 | }; 31 | }; 32 | -------------------------------------------------------------------------------- /apps/restaurant-cashier/router/page-outlet.component.js: -------------------------------------------------------------------------------- 1 | import { navigationEvents } from './router.js'; 2 | import { motionSettings } from '../users/preferences.service.js'; 3 | 4 | const pageOutlet = (component) => 5 | function* ({ router, $host, $signal, ...rest }) { 6 | const render = $host.render.bind($host); 7 | router.on( 8 | navigationEvents.PAGE_LOADED, 9 | ({ detail }) => render({ ...detail }), 10 | { signal: $signal }, 11 | ); 12 | 13 | yield* component({ 14 | $host, 15 | $signal, 16 | router, 17 | ...rest, 18 | }); 19 | }; 20 | 21 | export const PageOutlet = pageOutlet(function* ({ $host, preferencesService }) { 22 | while (true) { 23 | const { page } = yield; 24 | if (page) { 25 | const { motion } = preferencesService.getState(); 26 | const autofocusElement = page.content.querySelector('[autofocus]'); 27 | if ( 28 | !document.startViewTransition || 29 | motion?.computed === motionSettings.reduced 30 | ) { 31 | updateDOM({ page }); 32 | const elToFocus = autofocusElement || $host.querySelector('h1'); 33 | elToFocus?.focus(); 34 | } else { 35 | const transition = document.startViewTransition(() => { 36 | updateDOM({ page }); 37 | }); 38 | transition.updateCallbackDone.then(() => { 39 | const elToFocus = autofocusElement || $host.querySelector('h1'); 40 | elToFocus?.focus(); 41 | }); 42 | } 43 | } 44 | } 45 | 46 | function updateDOM({ page }) { 47 | $host.replaceChildren(page.content); 48 | document.title = page.title; 49 | } 50 | }); 51 | -------------------------------------------------------------------------------- /apps/open-library/book-list.component.js: -------------------------------------------------------------------------------- 1 | import { withBookListController } from './book-list.controller.js'; 2 | import { getElements, html } from './view.js'; 3 | 4 | export const BookListComponent = withBookListController(function* ({ 5 | controller, 6 | $host, 7 | }) { 8 | const targetId = $host.dataset.target; 9 | const targetSelector = `#${targetId}`; 10 | const parse = getElements(['form', '[type=submit]', targetSelector]); 11 | const { 12 | form: formEl, 13 | '[type=submit]': submitEl, 14 | [targetSelector]: targetEl, 15 | } = parse($host); 16 | 17 | $host.setAttribute('aria-controls', targetId); 18 | formEl.addEventListener('submit', handleSubmit); 19 | 20 | while (true) { 21 | const { state } = yield; 22 | const { isLoading = false, books = [], error = '' } = state; 23 | submitEl.disabled = isLoading; 24 | targetEl.innerHTML = renderSearchResults({ books }); 25 | targetEl.setAttribute('aria-busy', isLoading); 26 | } 27 | 28 | function handleSubmit(ev) { 29 | ev.preventDefault(); 30 | const { value: query } = formEl.elements.namedItem('search'); 31 | controller.search({ query }); 32 | } 33 | }); 34 | 35 | const renderSearchResults = ({ books }) => { 36 | return books 37 | .map(({ authorName, firstPublishYear, title }) => { 38 | return html`
  1. 39 |
    40 |

    ${title}

    41 |
    42 | Written by ${authorName} 43 | First published in ${firstPublishYear} 46 |
    47 |
    48 |
  2. `; 49 | }) 50 | .join(''); 51 | }; 52 | -------------------------------------------------------------------------------- /packages/view/src/index.js: -------------------------------------------------------------------------------- 1 | import { valueSymbol, findOrCreateTemplateRecord } from './active-site.js'; 2 | 3 | const shouldUpdateActiveSite = ([updateFn, newValue]) => 4 | !Object.is(updateFn[valueSymbol], newValue); 5 | 6 | const zip = (array1, array2) => 7 | array1.map((item, index) => [item, array2[index]]); 8 | 9 | export const withView = (viewFactory) => 10 | function* (deps) { 11 | const { $root, $signal } = deps; 12 | const templateCache = new Map(); 13 | const view = viewFactory({ 14 | ...deps, 15 | html: createHTML({ $signal, templateCache }), 16 | }); 17 | 18 | const viewFn = typeof view === 'function' ? view : () => view; 19 | 20 | const record = viewFn(yield); 21 | $root.replaceChildren(record.content); 22 | delete record.content; // free memory 23 | 24 | try { 25 | while (true) { 26 | viewFn(yield); 27 | } 28 | } finally { 29 | templateCache.clear(); 30 | } 31 | }; 32 | 33 | export const createHTML = 34 | ({ $signal, templateCache }) => 35 | (templateParts, ...interpolatedValues) => { 36 | const templateRecord = findOrCreateTemplateRecord({ 37 | templateParts, 38 | interpolatedValues, 39 | templateCache, 40 | $signal, 41 | }); 42 | 43 | const actualValues = templateRecord.isKeyed 44 | ? interpolatedValues.slice(1) // first value is used for the key 45 | : interpolatedValues; 46 | 47 | const toUpdate = zip(templateRecord.updateFns, actualValues).filter( 48 | shouldUpdateActiveSite, 49 | ); 50 | 51 | toUpdate.forEach(([updateFn, value]) => { 52 | updateFn[valueSymbol] = updateFn(value); 53 | }); 54 | 55 | return templateRecord; 56 | }; 57 | -------------------------------------------------------------------------------- /packages/di/src/index.js: -------------------------------------------------------------------------------- 1 | import { createInjector, factorify } from './injector.js'; 2 | 3 | const rootRegistry = {}; 4 | const registrySymbol = Symbol('registry'); 5 | export const provide = (providerFn) => (comp) => { 6 | const _providerFn = factorify(providerFn); 7 | return function* ({ $host, ...rest }) { 8 | let input = yield; // The element must be mounted, so we can look up the DOM tree 9 | $host[registrySymbol] = Object.assign( 10 | Object.create( 11 | $host.closest('[provider]')?.[registrySymbol] ?? rootRegistry, 12 | ), 13 | _providerFn({ $host, ...rest }), 14 | ); 15 | $host.toggleAttribute('provider'); 16 | 17 | const instance = comp({ 18 | $host, 19 | services: createInjector({ 20 | services: $host[registrySymbol], 21 | }), 22 | ...rest, 23 | }); 24 | 25 | instance.next(); // need to catch up as we defer instantiation 26 | try { 27 | while (true) { 28 | instance.next(input); 29 | input = yield; 30 | } 31 | } finally { 32 | instance.return(); 33 | } 34 | }; 35 | }; 36 | 37 | export const inject = (comp) => 38 | function* ({ $host, ...rest }) { 39 | let input = yield; // The element must be mounted, so we can look up the DOM tree 40 | const services = createInjector({ 41 | services: $host.closest('[provider]')?.[registrySymbol] ?? rootRegistry, 42 | }); 43 | const instance = comp({ $host, services, ...rest }); 44 | try { 45 | instance.next(); // need to catch up as we defer instantiation 46 | while (true) { 47 | instance.next(input); 48 | input = yield; 49 | } 50 | } finally { 51 | instance.return(); 52 | } 53 | }; 54 | -------------------------------------------------------------------------------- /apps/restaurant-cashier/products/list/product-list-item.component.js: -------------------------------------------------------------------------------- 1 | import { withView } from '@cofn/view'; 2 | import { compose } from '../../utils/functions.js'; 3 | import { withProps } from '@cofn/controllers'; 4 | 5 | const compositionPipeline = compose([withProps(['product']), withView]); 6 | 7 | export const ProductListItem = compositionPipeline(({ html, $host }) => { 8 | const onclickHandler = (ev) => { 9 | ev.stopPropagation(); 10 | ev.target.disabled = true; 11 | $host.dispatchEvent( 12 | new CustomEvent('product-removed', { 13 | bubbles: true, 14 | detail: { 15 | sku: $host.product.sku, 16 | }, 17 | }), 18 | ); 19 | }; 20 | 21 | return ({ properties: { product } }) => 22 | html`
    23 |
    24 |

    ${product.title}

    25 | 28 |
    29 |
    30 | ${product.image?.url 31 | ? html`product image` 32 | : null} 33 |
    34 |

    ${product.description ?? ''}

    35 |
    36 | #${product.sku} 42 | 43 | ${product.price.amountInCents / 100} 44 | ${product.price.currency} 45 | 46 |
    47 |
    `; 48 | }); 49 | -------------------------------------------------------------------------------- /apps/restaurant-cashier/router/trie.js: -------------------------------------------------------------------------------- 1 | export const createTrie = () => { 2 | const root = {}; 3 | return { 4 | insert(key) { 5 | const segments = getSegments(key); 6 | return insert({ segments }); 7 | }, 8 | search(value) { 9 | const segments = getSegments(value); 10 | const searchResult = search({ 11 | segments, 12 | }); 13 | const match = getSegments(searchResult?.path ?? '').join('/'); 14 | return { 15 | ...(searchResult ? searchResult : {}), 16 | match, 17 | }; 18 | }, 19 | }; 20 | 21 | function insert({ node = root, segments = [] } = {}) { 22 | if (segments.length === 0) { 23 | return; 24 | } 25 | 26 | const current = segments.shift(); 27 | 28 | if (!node[current]) { 29 | node[current] = {}; 30 | } 31 | 32 | return insert({ node: node[current], segments }); 33 | } 34 | 35 | function search({ node = root, segments = [], path = '', params = {} }) { 36 | const current = segments.shift(); 37 | 38 | if (!current) { 39 | return { path, params }; 40 | } 41 | 42 | const withParameterKey = Object.keys(node).find((key) => 43 | key.startsWith(':'), 44 | ); 45 | 46 | if (!node[current] && !withParameterKey) { 47 | return undefined; 48 | } 49 | 50 | return search({ 51 | node: node[current] || node[withParameterKey], 52 | segments, 53 | path: `${path}/${node[current] ? current : withParameterKey}`, 54 | params: { 55 | ...params, 56 | ...(withParameterKey ? { [withParameterKey.slice(1)]: current } : {}), 57 | }, 58 | }); 59 | } 60 | 61 | function getSegments(path) { 62 | return path.split('/').filter(Boolean); 63 | } 64 | }; 65 | -------------------------------------------------------------------------------- /apps/todomvc/views/todo-list.view.ts: -------------------------------------------------------------------------------- 1 | import { getModelFromState } from '../todo.model.ts'; 2 | import { TodoService, TodoServiceState } from '../todo.service.ts'; 3 | import { ViewFactory } from '@cofn/view'; 4 | export const TodoListView: ViewFactory< 5 | { todoService: TodoService }, 6 | { state: TodoServiceState } 7 | > = ({ html, todoService, $host, $signal }) => { 8 | bind('todo-toggled', todoService.toggleTodo); 9 | bind('todo-removed', (detail) => { 10 | todoService.removeTodo(detail); 11 | $host.focus(); 12 | }); 13 | const treeWalker = document.createTreeWalker($host, NodeFilter.SHOW_ELEMENT); 14 | $host.addEventListener('keydown', handleKeydown, { signal: $signal }); 15 | $host.addEventListener('focus', handleFocus, { signal: $signal }); 16 | 17 | return ({ state }) => { 18 | const { displayedItems = [] } = getModelFromState(state); 19 | 20 | return html` ${displayedItems.map( 21 | (todo) => 22 | html`${todo.id}::${todo.content}`, 28 | )}`; 29 | }; 30 | 31 | function bind(eventName, listener) { 32 | $host.addEventListener(eventName, ({ detail }) => listener(detail), { 33 | signal: $signal, 34 | }); 35 | } 36 | 37 | function handleKeydown({ key }) { 38 | if (key === 'ArrowDown' || key === 'ArrowUp') { 39 | const node = 40 | key === 'ArrowDown' ? treeWalker.nextNode() : treeWalker.previousNode(); 41 | treeWalker.currentNode = node || treeWalker.currentNode; 42 | // todo 43 | // @ts-ignore 44 | treeWalker.currentNode.focus(); 45 | } 46 | } 47 | 48 | function handleFocus() { 49 | treeWalker.currentNode = $host; 50 | } 51 | }; 52 | -------------------------------------------------------------------------------- /packages/test-lib/src/client/html-reporter/diagnostic.component.js: -------------------------------------------------------------------------------- 1 | const prettyPrint = (value) => JSON.stringify(value, null, 2); 2 | 3 | const diagnosticTemplate = document.createElement('template'); 4 | diagnosticTemplate.innerHTML = ` 5 |
    6 | 7 | 8 |
    9 | at 10 |
    11 |
    12 |
    13 |
    14 | actual 15 |
    
    16 |   
    17 |
    18 | expected 19 |
    
    20 |   
    21 |
    22 |
    23 | `; 24 | export class Diagnostic extends HTMLElement { 25 | #_diagnostic; 26 | 27 | get diagnostic() { 28 | return this.#_diagnostic; 29 | } 30 | 31 | set diagnostic(value) { 32 | this.#_diagnostic = value; 33 | this.render(); 34 | } 35 | 36 | connectedCallback() { 37 | this.replaceChildren(diagnosticTemplate.content.cloneNode(true)); 38 | } 39 | 40 | render() { 41 | if (!this.#_diagnostic) { 42 | return; 43 | } 44 | 45 | const { at, operator, description, expected, actual } = this.#_diagnostic; 46 | 47 | this.querySelector( 48 | '.description', 49 | ).textContent = `[${operator}] ${description}`; 50 | 51 | const locationElement = this.querySelector('.location'); 52 | const locationURL = new URL(at); 53 | const [_, row, column] = locationURL.search.split(':'); 54 | locationElement.textContent = `${locationURL.pathname}:${row}:${column}`; 55 | locationElement.setAttribute('href', at); 56 | 57 | const [actualEl, expectedEl] = this.querySelectorAll('pre'); 58 | actualEl.textContent = prettyPrint(actual); 59 | expectedEl.textContent = prettyPrint(expected); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /packages/view/src/range.js: -------------------------------------------------------------------------------- 1 | const comment = (data) => document.createComment(data); 2 | 3 | export const createRange = ({ node, templateCache }) => { 4 | const range = document.createRange(); 5 | const before = comment('before'); 6 | const after = comment('after'); 7 | let currentContent; 8 | node.before(before); 9 | node.after(after); 10 | return { 11 | replaceWith(newContent) { 12 | range.setStartAfter(before); 13 | range.setEndBefore(after); 14 | clearContent(newContent); 15 | 16 | if (Array.isArray(newContent)) { 17 | newContent.forEach((activeSite, index) => { 18 | const pivot = newContent[index - 1]?.content ?? before; 19 | if (pivot.nextElementSibling !== activeSite.content) { 20 | pivot.after(activeSite.content); 21 | } 22 | }); 23 | } else if (newContent?.content) { 24 | const { content } = newContent; 25 | range.insertNode(content); 26 | delete newContent.content; // drop useless fragment to free memory 27 | } 28 | return (currentContent = newContent); 29 | }, 30 | }; 31 | 32 | function clearContent(newContent) { 33 | if (!Array.isArray(currentContent)) { 34 | range.deleteContents(); 35 | templateCache.delete(currentContent?.key); 36 | } else { 37 | const oldGroup = groupByKey(currentContent); 38 | const newGroup = groupByKey(newContent); 39 | Object.entries(oldGroup) 40 | .filter(([key]) => !(key in newGroup)) 41 | .forEach(([, activeSite]) => { 42 | activeSite.content.remove(); 43 | templateCache.delete(activeSite.key); 44 | }); 45 | } 46 | } 47 | }; 48 | 49 | const groupByKey = (array) => 50 | array.reduce( 51 | (acc, curr) => ({ 52 | ...acc, 53 | [curr.key]: curr, 54 | }), 55 | {}, 56 | ); 57 | -------------------------------------------------------------------------------- /apps/restaurant-cashier/components/ui-alert.component.js: -------------------------------------------------------------------------------- 1 | import { createElement, createTextNode } from '../utils/dom.js'; 2 | import { notificationsEvents } from '../utils/notifications.service.js'; 3 | 4 | const template = createElement('template'); 5 | template.innerHTML = ` 24 |

    25 | `; 26 | 27 | const connectToNotifications = (comp) => 28 | function* ({ notificationsService, $signal, $host, ...rest }) { 29 | notificationsService.on( 30 | notificationsEvents.messagePublished, 31 | ({ detail }) => { 32 | if (detail.level === 'error') { 33 | $host.render({ notification: detail.payload }); 34 | } 35 | }, 36 | { signal: $signal }, 37 | ); 38 | 39 | yield* comp({ 40 | $host, 41 | $signal, 42 | ...rest, 43 | }); 44 | }; 45 | export const UIAlert = connectToNotifications(function* ({ 46 | $host, 47 | $root, 48 | $signal: signal, 49 | }) { 50 | const duration = $host.hasAttribute('duration') 51 | ? Number($host.getAttribute('duration')) 52 | : 4_000; 53 | const dismiss = () => $host.replaceChildren(); 54 | 55 | $root.appendChild(template.content.cloneNode(true)); 56 | $host.addEventListener('click', dismiss); 57 | $host.setAttribute('role', 'alert'); 58 | 59 | while (true) { 60 | const { notification } = yield; 61 | if (!$host.hasChildNodes() && notification?.message) { 62 | $host.replaceChildren(createTextNode(notification.message)); 63 | setTimeout(dismiss, duration, { signal }); 64 | } 65 | } 66 | }); 67 | -------------------------------------------------------------------------------- /packages/core/test/simple-component.test.js: -------------------------------------------------------------------------------- 1 | import { define } from '../src/index.js'; 2 | import { test } from '@cofn/test-lib/client'; 3 | import { nextTick } from './utils.js'; 4 | 5 | const debug = document.getElementById('debug'); 6 | define('simple-component', function* ({ $root, $host }) { 7 | $host.hasBeenRemoved = false; 8 | 9 | try { 10 | while (true) { 11 | const { content = 'simple component' } = yield; 12 | $root.textContent = content; 13 | } 14 | } finally { 15 | $host.hasBeenRemoved = true; 16 | } 17 | }); 18 | 19 | const withEl = (specFn) => 20 | async function zora_spec_fn(assert) { 21 | const el = document.createElement('simple-component'); 22 | debug.appendChild(el); 23 | return await specFn({ ...assert, el }); 24 | }; 25 | test( 26 | 'define a simple component from a coroutine', 27 | withEl(async ({ eq, el }) => { 28 | await nextTick(); 29 | eq(el.textContent, 'simple component'); 30 | }), 31 | ); 32 | 33 | test( 34 | 'content passed to render is injected to the coroutine', 35 | withEl(async ({ eq, el }) => { 36 | await nextTick(); 37 | eq(el.textContent, 'simple component'); 38 | el.render({ content: 'foo' }); 39 | await nextTick(); 40 | eq(el.textContent, 'foo'); 41 | el.render({ content: 'another foo' }); 42 | await nextTick(); 43 | eq(el.textContent, 'another foo'); 44 | }), 45 | ); 46 | 47 | test( 48 | 'when removed the finally flow is invoked', 49 | withEl(async ({ eq, el }) => { 50 | await nextTick(); 51 | eq(el.hasBeenRemoved, false); 52 | el.remove(); 53 | await nextTick(); 54 | eq(el.hasBeenRemoved, true); 55 | }), 56 | ); 57 | 58 | test( 59 | 'when moved around, the finally flow is not invoked', 60 | withEl(async ({ eq, el }) => { 61 | await nextTick(); 62 | eq(el.hasBeenRemoved, false); 63 | debug.prepend(el); 64 | await nextTick(); 65 | eq(el.hasBeenRemoved, false); 66 | }), 67 | ); 68 | -------------------------------------------------------------------------------- /packages/core/test/run-ci.js: -------------------------------------------------------------------------------- 1 | import { createServer } from 'vite'; 2 | import { firefox, chromium, webkit } from 'playwright'; 3 | 4 | const PORT = 3001; 5 | const TIMEOUT = 30_000; 6 | 7 | (async () => { 8 | let server, 9 | browsers = []; 10 | const browserList = [firefox, chromium, webkit]; 11 | try { 12 | server = await createServer({ 13 | server: { 14 | port: PORT, 15 | }, 16 | }); 17 | await server.listen(); 18 | 19 | browsers = await Promise.all( 20 | browserList.map((browserApp) => browserApp.launch({ headless: true })), 21 | ); 22 | 23 | await Promise.all( 24 | browsers.map((browser) => { 25 | console.log(browser._name); 26 | return new Promise((resolve, reject) => { 27 | let timerId; 28 | Promise.race([ 29 | browser 30 | .newPage() 31 | .then((page) => { 32 | page.on('websocket', (webSocket) => { 33 | webSocket.on('framesent', ({ payload }) => { 34 | const asJson = JSON.parse(payload); 35 | if (asJson?.data?.type === 'STREAM_ENDED') { 36 | clearTimeout(timerId); 37 | resolve(); 38 | } 39 | }); 40 | }); 41 | 42 | return page.goto( 43 | `http://localhost:${PORT}/test/test-suite.html`, 44 | ); 45 | }) 46 | .catch(reject), 47 | new Promise((resolve, reject) => { 48 | timerId = setTimeout(() => reject('timeout'), TIMEOUT); 49 | }), 50 | ]); 51 | }); 52 | }), 53 | ); 54 | } catch (e) { 55 | console.error(e); 56 | process.exitCode = 1; 57 | } finally { 58 | await Promise.all(browsers.map((browser) => browser.close())); 59 | if (server) { 60 | await server.close(); 61 | } 62 | } 63 | })(); 64 | -------------------------------------------------------------------------------- /packages/di/test/run-ci.js: -------------------------------------------------------------------------------- 1 | import { createServer } from 'vite'; 2 | import { firefox, chromium, webkit } from 'playwright'; 3 | 4 | const PORT = 3004; 5 | const TIMEOUT = 30_000; 6 | 7 | (async () => { 8 | let server, 9 | browsers = []; 10 | const browserList = [firefox, chromium, webkit]; 11 | try { 12 | server = await createServer({ 13 | server: { 14 | port: PORT, 15 | }, 16 | }); 17 | await server.listen(); 18 | 19 | browsers = await Promise.all( 20 | browserList.map((browserApp) => browserApp.launch({ headless: true })), 21 | ); 22 | 23 | await Promise.all( 24 | browsers.map((browser) => { 25 | console.log(browser._name); 26 | return new Promise((resolve, reject) => { 27 | let timerId; 28 | Promise.race([ 29 | browser 30 | .newPage() 31 | .then((page) => { 32 | page.on('websocket', (webSocket) => { 33 | webSocket.on('framesent', ({ payload }) => { 34 | const asJson = JSON.parse(payload); 35 | if (asJson?.data?.type === 'STREAM_ENDED') { 36 | clearTimeout(timerId); 37 | resolve(); 38 | } 39 | }); 40 | }); 41 | 42 | return page.goto( 43 | `http://localhost:${PORT}/test/test-suite.html`, 44 | ); 45 | }) 46 | .catch(reject), 47 | new Promise((resolve, reject) => { 48 | timerId = setTimeout(() => reject('timeout'), TIMEOUT); 49 | }), 50 | ]); 51 | }); 52 | }), 53 | ); 54 | } catch (e) { 55 | console.error(e); 56 | process.exitCode = 1; 57 | } finally { 58 | await Promise.all(browsers.map((browser) => browser.close())); 59 | if (server) { 60 | await server.close(); 61 | } 62 | } 63 | })(); 64 | -------------------------------------------------------------------------------- /packages/view/test/run-ci.js: -------------------------------------------------------------------------------- 1 | import { createServer } from 'vite'; 2 | import { firefox, chromium, webkit } from 'playwright'; 3 | 4 | const PORT = 3002; 5 | const TIMEOUT = 30_000; 6 | 7 | (async () => { 8 | let server, 9 | browsers = []; 10 | const browserList = [firefox, chromium, webkit]; 11 | try { 12 | server = await createServer({ 13 | server: { 14 | port: PORT, 15 | }, 16 | }); 17 | await server.listen(); 18 | 19 | browsers = await Promise.all( 20 | browserList.map((browserApp) => browserApp.launch({ headless: true })), 21 | ); 22 | 23 | await Promise.all( 24 | browsers.map((browser) => { 25 | console.log(browser._name); 26 | return new Promise((resolve, reject) => { 27 | let timerId; 28 | Promise.race([ 29 | browser 30 | .newPage() 31 | .then((page) => { 32 | page.on('websocket', (webSocket) => { 33 | webSocket.on('framesent', ({ payload }) => { 34 | const asJson = JSON.parse(payload); 35 | if (asJson?.data?.type === 'STREAM_ENDED') { 36 | clearTimeout(timerId); 37 | resolve(); 38 | } 39 | }); 40 | }); 41 | 42 | return page.goto( 43 | `http://localhost:${PORT}/test/test-suite.html`, 44 | ); 45 | }) 46 | .catch(reject), 47 | new Promise((resolve, reject) => { 48 | timerId = setTimeout(() => reject('timeout'), TIMEOUT); 49 | }), 50 | ]); 51 | }); 52 | }), 53 | ); 54 | } catch (e) { 55 | console.error(e); 56 | process.exitCode = 1; 57 | } finally { 58 | await Promise.all(browsers.map((browser) => browser.close())); 59 | if (server) { 60 | await server.close(); 61 | } 62 | } 63 | })(); 64 | -------------------------------------------------------------------------------- /packages/controllers/test/run-ci.js: -------------------------------------------------------------------------------- 1 | import { createServer } from 'vite'; 2 | import { firefox, chromium, webkit } from 'playwright'; 3 | 4 | const PORT = 3003; 5 | const TIMEOUT = 30_000; 6 | 7 | (async () => { 8 | let server, 9 | browsers = []; 10 | const browserList = [firefox, chromium, webkit]; 11 | try { 12 | server = await createServer({ 13 | server: { 14 | port: PORT, 15 | }, 16 | }); 17 | await server.listen(); 18 | 19 | browsers = await Promise.all( 20 | browserList.map((browserApp) => browserApp.launch({ headless: true })), 21 | ); 22 | 23 | await Promise.all( 24 | browsers.map((browser) => { 25 | console.log(browser._name); 26 | return new Promise((resolve, reject) => { 27 | let timerId; 28 | Promise.race([ 29 | browser 30 | .newPage() 31 | .then((page) => { 32 | page.on('websocket', (webSocket) => { 33 | webSocket.on('framesent', ({ payload }) => { 34 | const asJson = JSON.parse(payload); 35 | if (asJson?.data?.type === 'STREAM_ENDED') { 36 | clearTimeout(timerId); 37 | resolve(); 38 | } 39 | }); 40 | }); 41 | 42 | return page.goto( 43 | `http://localhost:${PORT}/test/test-suite.html`, 44 | ); 45 | }) 46 | .catch(reject), 47 | new Promise((resolve, reject) => { 48 | timerId = setTimeout(() => reject('timeout'), TIMEOUT); 49 | }), 50 | ]); 51 | }); 52 | }), 53 | ); 54 | } catch (e) { 55 | console.error(e); 56 | process.exitCode = 1; 57 | } finally { 58 | await Promise.all(browsers.map((browser) => browser.close())); 59 | if (server) { 60 | await server.close(); 61 | } 62 | } 63 | })(); 64 | -------------------------------------------------------------------------------- /apps/restaurant-cashier/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Restaurant Cashier 6 | 7 | 8 | 9 | 10 | 11 | 12 |
    13 |
    14 | 20 | 39 |
    40 |
    41 |
    42 | 43 |
    44 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /packages/view/test/if-dom.test.js: -------------------------------------------------------------------------------- 1 | import { test } from '@cofn/test-lib/client'; 2 | import { fromView, nextTick } from './utils.js'; 3 | 4 | const debug = document.getElementById('debug'); 5 | 6 | test('elements can be added or removed with conditional expression', async ({ 7 | eq, 8 | }) => { 9 | const el = fromView( 10 | ({ html }) => 11 | ({ showItem }) => 12 | // prettier-ignore 13 | html`
    • item1
    • ${showItem === true ? html`
    • item2
    • ` : null}
    • item3
    `, 14 | ); 15 | 16 | debug.appendChild(el); 17 | await nextTick(); 18 | eq( 19 | el.innerHTML, 20 | `
    • item1
    • item3
    `, 21 | ); 22 | el.render({ showItem: true }); 23 | await nextTick(); 24 | eq( 25 | el.innerHTML, 26 | `
    • item1
    • item2
    • item3
    `, 27 | ); 28 | el.render({ showItem: false }); 29 | await nextTick(); 30 | eq( 31 | el.innerHTML, 32 | `
    • item1
    • item3
    `, 33 | ); 34 | }); 35 | 36 | test('elements can be swapped depending on a condition', async ({ eq }) => { 37 | const el = fromView( 38 | ({ html }) => 39 | ({ showItem }) => 40 | // prettier-ignore 41 | html`
    • item1
    • ${showItem === true ? html`
    • item2
    • ` : html`
    • item2bis
    • `}
    • item3
    `, 42 | ); 43 | 44 | debug.appendChild(el); 45 | await nextTick(); 46 | eq( 47 | el.innerHTML, 48 | `
    • item1
    • item2bis
    • item3
    `, 49 | ); 50 | el.render({ showItem: true }); 51 | await nextTick(); 52 | eq( 53 | el.innerHTML, 54 | `
    • item1
    • item2
    • item3
    `, 55 | ); 56 | el.render({ showItem: false }); 57 | await nextTick(); 58 | eq( 59 | el.innerHTML, 60 | `
    • item1
    • item2bis
    • item3
    `, 61 | ); 62 | }); 63 | -------------------------------------------------------------------------------- /apps/restaurant-cashier/products/list/product-list.component.js: -------------------------------------------------------------------------------- 1 | import { querySelector } from '../../utils/dom.js'; 2 | 3 | export const ProductList = ({ 4 | html, 5 | $host, 6 | animationService, 7 | productListService, 8 | }) => { 9 | setTimeout(productListService.fetch, 200); // todo remove (when we have a real backend and do not rely on service worker) 10 | 11 | $host.addEventListener('product-removed', async ({ detail }) => { 12 | const { sku } = detail; 13 | const itemToRemove = $host.querySelector(`[data-id=${sku}]`); 14 | await animationService.removeElement(itemToRemove); 15 | productListService.remove({ sku }); 16 | }); 17 | 18 | function transitionCard() { 19 | // remove any precedent reduced card 20 | querySelector('.transition-card-collapse')?.classList.remove( 21 | 'transition-card-collapse', 22 | ); 23 | this.classList.add('transition-card-expand'); 24 | } 25 | 26 | return ({ products, attributes }) => { 27 | const { ['target-sku']: targetSku } = attributes; 28 | return html` 29 |

    Product list

    30 |
    31 | 37 | ${products.map((product) => { 38 | const classList = [ 39 | 'boxed', 40 | 'surface', 41 | ...(product.sku === targetSku ? ['transition-card-collapse'] : []), 42 | ]; 43 | return html`${product.sku}::`; 49 | })} 50 |
    51 | `; 52 | }; 53 | }; 54 | -------------------------------------------------------------------------------- /apps/todomvc/todo.model.ts: -------------------------------------------------------------------------------- 1 | import { not } from './utils.ts'; 2 | 3 | export type Todo = { 4 | id: number; 5 | content: string; 6 | completed: boolean; 7 | }; 8 | 9 | export type Filter = 'all' | 'completed' | 'to-be-done'; 10 | const isCompleted = ({ completed }: Todo) => completed; 11 | export const getModelFromState = ({ 12 | todos, 13 | filter = 'all', 14 | }: { 15 | todos: Todo[]; 16 | filter?: Filter; 17 | }) => { 18 | const toBeCompletedCount = todos.filter(({ completed }) => !completed).length; 19 | const hasAnyCompleted = toBeCompletedCount < todos.length; 20 | return { 21 | displayedItems: 22 | filter === 'all' 23 | ? todos 24 | : filter === 'completed' 25 | ? todos.filter(isCompleted) 26 | : todos.filter(not(isCompleted)), 27 | toBeCompletedCount, 28 | hasAnyCompleted, 29 | hasAnyItem: todos.length > 0, 30 | areAllCompleted: toBeCompletedCount === 0 && hasAnyCompleted, 31 | filter, 32 | }; 33 | }; 34 | 35 | const generateNewId = ({ todos }: { todos: Todo[] }) => 36 | Math.max(0, ...todos.map(({ id }) => id)) + 1; 37 | export const addTodo = ({ 38 | content, 39 | todos, 40 | newId = generateNewId, 41 | }: { 42 | content: string; 43 | todos: Todo[]; 44 | newId?: ({ todos }: { todos: Todo[] }) => number; 45 | }) => [...todos, { content, id: newId({ todos }), completed: false }]; 46 | 47 | export const removeTodo = ({ todos, id }: { todos: Todo[]; id: number }) => 48 | todos.filter(({ id: todoId }) => todoId !== id); 49 | 50 | export const toggleTodo = ({ todos, id }: { todos: Todo[]; id: number }) => 51 | todos.map((todo) => 52 | todo.id === id ? { ...todo, completed: !todo.completed } : todo, 53 | ); 54 | 55 | export const clearCompleted = ({ todos }: { todos: Todo[] }) => 56 | todos.filter(not(isCompleted)); 57 | 58 | export const toggleAll = ({ todos }: { todos: Todo[] }) => { 59 | const { areAllCompleted } = getModelFromState({ todos }); 60 | return todos.map((todo) => ({ 61 | ...todo, 62 | completed: !areAllCompleted, 63 | })); 64 | }; 65 | -------------------------------------------------------------------------------- /apps/todomvc/views/todo-item.view.ts: -------------------------------------------------------------------------------- 1 | import { ViewFactory } from '@cofn/view'; 2 | 3 | export const TodoItemView: ViewFactory = ({ html, $host }) => { 4 | const id = Number($host.getAttribute('data-id')); 5 | const handleChange = dispatch('todo-toggled'); 6 | const handleClick = dispatch('todo-removed'); 7 | 8 | return ({ attributes }) => 9 | html` 45 | 56 | `; 59 | 60 | function dispatch(eventName) { 61 | return (ev) => { 62 | ev.stopPropagation(); 63 | $host.dispatchEvent( 64 | new CustomEvent(eventName, { 65 | bubbles: true, 66 | detail: { 67 | id, 68 | }, 69 | }), 70 | ); 71 | }; 72 | } 73 | }; 74 | -------------------------------------------------------------------------------- /packages/view/test/attributes.test.js: -------------------------------------------------------------------------------- 1 | import { test } from '@cofn/test-lib/client'; 2 | import { fromView, nextTick } from './utils.js'; 3 | 4 | const debug = document.getElementById('debug'); 5 | 6 | test('attribute can be interpolated', async ({ eq }) => { 7 | const el = fromView( 8 | ({ html }) => 9 | ({ attributes }) => 10 | html`

    hello

    `, 11 | ); 12 | el.setAttribute('item-id', 'someId'); 13 | debug.appendChild(el); 14 | await nextTick(); 15 | eq(el.querySelector('p').getAttribute('data-id'), 'someId'); 16 | el.render({ 17 | attributes: { 18 | ['item-id']: 'otherId', 19 | }, 20 | }); 21 | await nextTick(); 22 | 23 | eq(el.querySelector('p').getAttribute('data-id'), 'otherId'); 24 | }); 25 | 26 | test('attribute is set when value type is boolean and value is "true", attribute is removed when value is "false"', async ({ 27 | eq, 28 | }) => { 29 | const el = fromView( 30 | ({ html }) => 31 | ({ data } = {}) => 32 | html`

    hello

    `, 33 | ); 34 | debug.appendChild(el); 35 | 36 | await nextTick(); 37 | 38 | const pEl = el.querySelector('p'); 39 | el.render({ 40 | data: { 41 | open: true, 42 | }, 43 | }); 44 | await nextTick(); 45 | 46 | eq(pEl.getAttribute('open'), 'true'); 47 | eq(pEl.hasAttribute('open'), true); 48 | el.render({ 49 | data: { 50 | open: false, 51 | }, 52 | }); 53 | await nextTick(); 54 | 55 | eq(pEl.hasAttribute('open'), false); 56 | }); 57 | 58 | test('attribute starting with "." sets a property', async ({ eq }) => { 59 | const el = fromView( 60 | ({ html }) => 61 | ({ value }) => 62 | html``, 63 | ); 64 | 65 | debug.appendChild(el); 66 | await nextTick(); 67 | const input = el.firstElementChild; 68 | eq(input.value, ''); 69 | el.render({ value: 'hello' }); 70 | await nextTick(); 71 | eq(input.value, 'hello'); 72 | el.render({ value: 'hello world' }); 73 | await nextTick(); 74 | eq(input.value, 'hello world'); 75 | }); 76 | -------------------------------------------------------------------------------- /apps/restaurant-cashier/users/me.css: -------------------------------------------------------------------------------- 1 | #preferences { 2 | --_spacing: var(--spacing-small); 3 | padding: var(--_spacing); 4 | } 5 | 6 | app-preferences { 7 | font-size: 0.9em; 8 | 9 | .legend { 10 | padding-left: var(--_spacing); 11 | } 12 | } 13 | 14 | 15 | /** 16 | todo eventually draw what is related to radio group into forms.css 17 | */ 18 | 19 | fieldset { 20 | container: fieldset / inline-size; 21 | margin-top: var(--_spacing); 22 | display: flex; 23 | align-items: center; 24 | flex-wrap: wrap; 25 | justify-content: space-between; 26 | gap: var(--_spacing) calc(var(--_spacing) / 2); 27 | 28 | &:focus-within .legend { 29 | text-decoration: underline solid var(--action-color); 30 | } 31 | } 32 | 33 | .radio-group { 34 | display: grid; 35 | grid-template-columns: max-content 1fr 1fr; 36 | width: min(100%, 20em); 37 | border-radius: var(--border-radius); 38 | border: 1px solid var(--form-border-color); 39 | 40 | label { 41 | flex-direction: row; 42 | padding: calc(var(--_spacing) / 2) var(--_spacing); 43 | transition: background-color var(--animation-duration), color var(--animation-duration); 44 | 45 | &:has(input[checked]) { 46 | background-color: var(--action-color); 47 | color: var(--action-color-contrast); 48 | } 49 | 50 | &:not(:last-child) { 51 | border-inline-end: 1px solid var(--form-border-color); 52 | } 53 | } 54 | 55 | 56 | } 57 | 58 | input[type=radio] { 59 | box-shadow: unset; 60 | margin-right: var(--_spacing); 61 | accent-color: var(--action-color); 62 | 63 | &:focus ~ span { 64 | outline: 1px dotted; 65 | } 66 | } 67 | 68 | @container fieldset (max-width: 520px) { 69 | .radio-group { 70 | display: flex; 71 | flex-direction: column; 72 | width: 100%; 73 | 74 | label:not(:last-child) { 75 | border-inline-end: unset; 76 | border-block-end: 1px solid var(--form-border-color); 77 | } 78 | 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /packages/test-lib/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@cofn/test-lib", 3 | "version": "1.0.0", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "@cofn/test-lib", 9 | "version": "1.0.0", 10 | "dependencies": { 11 | "zora": "^5.2.0", 12 | "zora-reporters": "^1.4.0" 13 | } 14 | }, 15 | "node_modules/arg": { 16 | "version": "5.0.2", 17 | "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", 18 | "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", 19 | "license": "MIT" 20 | }, 21 | "node_modules/colorette": { 22 | "version": "2.0.17", 23 | "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.17.tgz", 24 | "integrity": "sha512-hJo+3Bkn0NCHybn9Tu35fIeoOKGOk5OCC32y4Hz2It+qlCO2Q3DeQ1hRn/tDDMQKRYUEzqsl7jbF6dYKjlE60g==", 25 | "license": "MIT" 26 | }, 27 | "node_modules/diff": { 28 | "version": "5.1.0", 29 | "resolved": "https://registry.npmjs.org/diff/-/diff-5.1.0.tgz", 30 | "integrity": "sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw==", 31 | "license": "BSD-3-Clause", 32 | "engines": { 33 | "node": ">=0.3.1" 34 | } 35 | }, 36 | "node_modules/zora": { 37 | "version": "5.2.0", 38 | "resolved": "https://registry.npmjs.org/zora/-/zora-5.2.0.tgz", 39 | "integrity": "sha512-FSZOvfJVfMWhk/poictNsDBCXq/Z+2Zu2peWs6d8OhWWb9nY++czw95D47hdw06L/kfjasLevwrbUtnXyWLAJw==", 40 | "license": "MIT" 41 | }, 42 | "node_modules/zora-reporters": { 43 | "version": "1.4.0", 44 | "resolved": "https://registry.npmjs.org/zora-reporters/-/zora-reporters-1.4.0.tgz", 45 | "integrity": "sha512-RZy2zb/aT8YKUztELGjFWMb39LduCEB4SHoAH4w2HBWKE53V0lU385AAK2q097P7D5cdMLGJIYXpykxUdoyyig==", 46 | "license": "MIT", 47 | "dependencies": { 48 | "arg": "~5.0.2", 49 | "colorette": "2.0.17", 50 | "diff": "~5.1.0" 51 | }, 52 | "bin": { 53 | "zr": "src/bin.js" 54 | } 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /packages/di/readme.md: -------------------------------------------------------------------------------- 1 | # DI 2 | 3 | Dependency injection container which leans on the DOM 4 | 5 | ## Installation 6 | 7 | you can install the library with a package manager (like npm): 8 | ``npm install @cofn/di`` 9 | 10 | Or import it directly from a CDN 11 | 12 | ```js 13 | import {provide, inject} from 'https://unpkg.com/@cofn/di/dist/cofn-di.js'; 14 | ``` 15 | 16 | ## usage 17 | 18 | ### provide 19 | 20 | ``provide`` is a higher order function which takes as input either a function which returns a map of injectables or a map of injectables. 21 | If it is a function it takes all as input all the dependencies of the bound generator (ie ``$host``, etc). 22 | 23 | The map is an object whose keys are injection tokens (by name or symbol) and the values are factory functions (functions used to create an "instance" of that injectable) or values. 24 | These factories can themselves depends on other injectables: 25 | 26 | ```js 27 | import {provide} from '@cofn/di'; 28 | import {define} from '@cofn/core'; 29 | 30 | const withAB = provide({ 31 | a: ({b}) => 'a' + b, 32 | b: 'c' 33 | }); 34 | 35 | define('some-provider', withAB(function*(){ 36 | 37 | })); 38 | ``` 39 | 40 | When a child element of the DOM tree is also a provider, it can override dependencies for its descendants 41 | 42 | ```js 43 | const withbbis = provide({ 44 | a: ({b}) => 'a' + b, 45 | b: 'otherC' 46 | }); 47 | 48 | define('some-other-provider', withAB(function*(){ 49 | 50 | })); 51 | ``` 52 | 53 | ```html 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | ``` 63 | 64 | ### inject 65 | 66 | A higher order function to declare that the component will have the map of services injected. Services are instantiated only when you call the getter related to a specific injected 67 | 68 | ```js 69 | import { inject } from '@cofn/di'; 70 | 71 | define('some-el', inject(function* ({ services }) { 72 | 73 | const { b } = services; // only b is instantiated 74 | // etc 75 | 76 | })); 77 | ``` 78 | 79 | 80 | 81 | 82 | -------------------------------------------------------------------------------- /packages/view/test/event-listeners.test.js: -------------------------------------------------------------------------------- 1 | import { test } from '@cofn/test-lib/client'; 2 | import { fromView, nextTick } from './utils.js'; 3 | 4 | const debug = document.getElementById('debug'); 5 | test('attribute starting with a @ is an event listener', async ({ eq }) => { 6 | let count = 0; 7 | 8 | const listener = () => (count += 1); 9 | 10 | const el = fromView( 11 | ({ html }) => 12 | () => 13 | html``, 14 | ); 15 | 16 | debug.appendChild(el); 17 | 18 | await nextTick(); 19 | 20 | eq(count, 0); 21 | 22 | el.firstElementChild.click(); 23 | 24 | await nextTick(); 25 | 26 | eq(count, 1); 27 | }); 28 | 29 | test('when updated, legacy listener is removed while new listener is attached', async ({ 30 | eq, 31 | }) => { 32 | let count = 0; 33 | 34 | const listener1 = () => (count += 1); 35 | const listener2 = () => (count += 2); 36 | 37 | const el = fromView( 38 | ({ html }) => 39 | ({ onclick = listener1 }) => 40 | html``, 41 | ); 42 | 43 | debug.appendChild(el); 44 | 45 | await nextTick(); 46 | 47 | eq(count, 0); 48 | 49 | const button = el.firstElementChild; 50 | 51 | button.click(); 52 | 53 | await nextTick(); 54 | 55 | eq(count, 1); 56 | 57 | el.render({ 58 | onclick: listener2, 59 | }); 60 | 61 | await nextTick(); 62 | 63 | button.click(); 64 | 65 | await nextTick(); 66 | 67 | eq(count, 3); 68 | }); 69 | 70 | test('when element is removed all listeners are removed', async ({ eq }) => { 71 | let count = 0; 72 | const listener = () => { 73 | count += 1; 74 | }; 75 | 76 | const el = fromView(({ html, $signal }) => { 77 | return () => html``; 78 | }); 79 | 80 | debug.appendChild(el); 81 | 82 | await nextTick(); 83 | 84 | eq(count, 0); 85 | 86 | const button = el.firstElementChild; 87 | button.click(); 88 | 89 | await nextTick(); 90 | 91 | eq(count, 1); 92 | 93 | el.remove(); 94 | 95 | await nextTick(); 96 | 97 | button.click(); 98 | 99 | await nextTick(); 100 | 101 | eq(count, 1); 102 | }); 103 | -------------------------------------------------------------------------------- /packages/view/src/tree.js: -------------------------------------------------------------------------------- 1 | export const ACTIVE_SITE_POINTER = '__AS__'; 2 | 3 | export const traverseTree = (templateElement) => 4 | [..._traverseTree(createTreeWalker(templateElement))].flatMap(prepareNode); 5 | 6 | const createTreeWalker = (fragment) => 7 | document.createTreeWalker( 8 | fragment, 9 | NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT, 10 | { 11 | acceptNode(node) { 12 | // Element attributes 13 | if (node.nodeType === 1) { 14 | const hasMatchingAttribute = [...node.attributes].some((attr) => 15 | attr.value?.includes(ACTIVE_SITE_POINTER), 16 | ); 17 | return hasMatchingAttribute 18 | ? NodeFilter.FILTER_ACCEPT 19 | : NodeFilter.FILTER_SKIP; 20 | } 21 | // text 22 | if (node.nodeType === 3) { 23 | return node.textContent.includes(ACTIVE_SITE_POINTER) 24 | ? NodeFilter.FILTER_ACCEPT 25 | : NodeFilter.FILTER_REJECT; 26 | } 27 | 28 | return NodeFilter.FILTER_REJECT; 29 | }, 30 | }, 31 | ); 32 | 33 | function* _traverseTree(treeWalker) { 34 | let { currentNode } = treeWalker; 35 | currentNode = 36 | currentNode.nodeType === 11 ? treeWalker.nextNode() : currentNode; // if a document fragment we "enter" the fragment 37 | while (currentNode) { 38 | yield* currentNode.nodeType === 1 39 | ? new Set( 40 | [...currentNode.attributes].filter((attr) => 41 | attr.value?.includes(ACTIVE_SITE_POINTER), 42 | ), 43 | ) 44 | : [currentNode]; 45 | currentNode = treeWalker.nextNode(); 46 | } 47 | } 48 | 49 | const prepareNode = (node) => { 50 | if (node.nodeType === 3) { 51 | return node.textContent === ACTIVE_SITE_POINTER 52 | ? node 53 | : [...splitTextNode(node)]; 54 | } 55 | return node; 56 | }; 57 | 58 | function* splitTextNode(node) { 59 | if (!node.textContent.includes(ACTIVE_SITE_POINTER)) { 60 | return; 61 | } 62 | const activeSiteIndex = node.textContent.indexOf(ACTIVE_SITE_POINTER); 63 | const next = node.splitText(activeSiteIndex + ACTIVE_SITE_POINTER.length); 64 | const activeSite = document.createTextNode(''); 65 | const range = new Range(); 66 | range.setStart(node, activeSiteIndex); 67 | range.setEndAfter(node); 68 | range.deleteContents(); 69 | range.insertNode(activeSite); 70 | yield activeSite; 71 | yield* splitTextNode(next); 72 | } 73 | -------------------------------------------------------------------------------- /packages/controllers/readme.md: -------------------------------------------------------------------------------- 1 | # Controllers 2 | 3 | A set of higher order function to add update logic to a coroutine component 4 | 5 | ## Installation 6 | 7 | you can install the library with a package manager (like npm): 8 | ``npm install @cofn/controllers`` 9 | 10 | Or import it directly from a CDN 11 | 12 | ```js 13 | import {withController} from 'https://unpkg.com/@cofn/controllers/dist/cofn-controllers.js'; 14 | ``` 15 | 16 | ## Reactive props 17 | 18 | Defines a list of properties to watch. The namespace ``properties`` is injected into the rendering generator 19 | 20 | ```js 21 | import {define} from '@cofn/core'; 22 | import {withProps} from '@cofn/controllers' 23 | 24 | const withName = withProps(['name']); 25 | 26 | define('my-comp', withNameAndAge(function *({$root}){ 27 | while(true) { 28 | const { properties } = yield; 29 | $root.textContent = properties.name; 30 | } 31 | })); 32 | 33 | // 34 | 35 | myCompEl.name = 'Bob'; // > render 36 | 37 | // ... 38 | 39 | myCompEl.name = 'Woot'; // > render 40 | 41 | ``` 42 | 43 | ## Controller API 44 | 45 | Defines a controller passed to the rendering generator. Takes as input a factory function which returns the controller. 46 | 47 | The regular component dependencies are injected into the controller factory and a meta object ``state``. 48 | Whenever a property is set on this meta object, the component renders. The namespace ``state`` is injected into the rendering generator. 49 | 50 | ```js 51 | import {define} from '@cofn/core'; 52 | import {withController} from '@cofn/controller'; 53 | 54 | const withCountController = withController(({state, $host}) => { 55 | const step = $host.hasAttribute('step') ? Number($host.getAttribute('step')) : 1; 56 | state.count = 0; 57 | 58 | return { 59 | increment(){ 60 | state = state + step; 61 | }, 62 | decrement(){ 63 | state = state - step; 64 | } 65 | }; 66 | }); 67 | 68 | define('count-me',withCountController(function *({$root, controller}){ 69 | $root.replaceChildren(template.content.cloneNode(true)); 70 | const [decrementEl, incrementEl] = $host.querySelectorAll('button'); 71 | const countEl = $host.querySelector('span'); 72 | 73 | decrementEl.addEventListener('click', controller.decrement); 74 | incrementEl.addEventListener('click', controller.increment); 75 | 76 | while(true) { 77 | const { $scope } = yield; 78 | countEl.textContent = $scope.count; 79 | } 80 | })); 81 | ``` 82 | -------------------------------------------------------------------------------- /apps/todomvc/todo.service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | addTodo, 3 | clearCompleted, 4 | Filter, 5 | removeTodo, 6 | Todo, 7 | toggleAll, 8 | toggleTodo, 9 | } from './todo.model.ts'; 10 | import { mapValues } from './utils.js'; 11 | 12 | const { localStorage } = window; 13 | 14 | export type TodoServiceState = { 15 | filter: Filter; 16 | todos: Todo[]; 17 | }; 18 | 19 | export type TodoService = { 20 | addEventListener: EventTarget['addEventListener']; 21 | getState: () => TodoServiceState; 22 | updateFilter: ({ filter }: { filter: Filter }) => void; 23 | addTodo: (newTodo: { content: string }) => void; 24 | removeTodo: ({ id }: { id: number }) => void; 25 | toggleTodo: ({ id }: { id: number }) => void; 26 | toggleAll: (input?: Record) => void; 27 | clearCompleted: (input?: Record) => void; 28 | }; 29 | 30 | type TodoMethod = (input: Input) => Todo[]; 31 | 32 | const createService = ({ 33 | emitter = new EventTarget(), 34 | todos: initialTodos = [], 35 | filter: initialFilter = 'all', 36 | }: Partial & { 37 | emitter?: EventTarget; 38 | } = {}): TodoService => { 39 | let todos = [...initialTodos]; 40 | let filter: Filter = initialFilter; 41 | 42 | const getState = () => structuredClone({ todos, filter }); 43 | 44 | const withEmitter = mapValues((method: TodoMethod) => (args) => { 45 | todos = method({ ...args, todos }); 46 | emitter.dispatchEvent(new CustomEvent('state-changed')); 47 | }); 48 | 49 | return { 50 | addEventListener: (...args: Parameters) => 51 | emitter.addEventListener(...args), 52 | getState, 53 | ...withEmitter({ 54 | addTodo, 55 | removeTodo, 56 | toggleTodo, 57 | clearCompleted, 58 | toggleAll, 59 | }), 60 | updateFilter({ filter: newFilter }: { filter: Filter }) { 61 | filter = newFilter; 62 | emitter.dispatchEvent(new CustomEvent('state-changed')); 63 | }, 64 | }; 65 | }; 66 | 67 | const initialState = JSON.parse(localStorage.getItem('state') || '{}'); 68 | const defaultImpl = createService(initialState); 69 | 70 | defaultImpl.addEventListener('state-changed', () => 71 | localStorage.setItem('state', JSON.stringify(defaultImpl.getState())), 72 | ); 73 | 74 | export const injectTodoService = 75 | (fn: (deps: T) => K) => 76 | (arg?: Omit) => 77 | fn({ 78 | ...(arg ?? {}), 79 | todoService: defaultImpl, 80 | } as T); 81 | -------------------------------------------------------------------------------- /apps/todomvc/views/todo-list-controls.view.ts: -------------------------------------------------------------------------------- 1 | import { getModelFromState } from '../todo.model.ts'; 2 | import { ViewFactory } from '@cofn/view'; 3 | import { TodoService, TodoServiceState } from '../todo.service.ts'; 4 | 5 | export const todoListControlsView: ViewFactory< 6 | { todoService: TodoService }, 7 | { state: TodoServiceState } 8 | > = ({ html, todoService }) => { 9 | const handleChange = ({ target }) => 10 | todoService.updateFilter({ filter: target.value }); 11 | 12 | return ({ state }) => { 13 | const { 14 | toBeCompletedCount, 15 | areAllCompleted, 16 | hasAnyCompleted, 17 | hasAnyItem, 18 | filter, 19 | } = getModelFromState(state); 20 | return html`${hasAnyItem 21 | ? html`
    22 | What tasks do you want to see ? 23 |
    24 | 33 | 42 | 51 |
    52 |
    53 |

    54 | ${toBeCompletedCount} item${toBeCompletedCount > 1 ? 's' : ''} left 55 |

    56 | 63 | ${hasAnyCompleted 64 | ? html`` 71 | : null} ` 72 | : null}`; 73 | }; 74 | }; 75 | -------------------------------------------------------------------------------- /packages/core/src/component.js: -------------------------------------------------------------------------------- 1 | const defaultOptions = Object.freeze({ 2 | observedAttributes: [], 3 | Klass: HTMLElement, 4 | }); 5 | export const component = (renderLoop, opts = defaultOptions) => { 6 | const { observedAttributes = [], Klass = HTMLElement, shadow } = opts; 7 | return class extends Klass { 8 | #loop; 9 | #abortController; 10 | #updateStack = []; 11 | 12 | static get observedAttributes() { 13 | return [...observedAttributes]; 14 | } 15 | 16 | constructor() { 17 | super(); 18 | this.#abortController = new AbortController(); 19 | const $host = this; 20 | const $root = shadow !== undefined ? $host.attachShadow(shadow) : $host; 21 | this.#loop = renderLoop.bind(this)({ 22 | $host, 23 | $root, 24 | $signal: this.#abortController.signal, 25 | }); 26 | this.render = this.render.bind(this); 27 | this.#loop.next(); 28 | } 29 | 30 | connectedCallback() { 31 | this.render(); 32 | } 33 | 34 | // connectedMoveCallback() { 35 | // noop 36 | // } 37 | 38 | disconnectedCallback() { 39 | // we end the rendering loop only if the component is removed from de DOM. Sometimes it is just moved from one place to another one 40 | // and connectedMoveCallback is not yet fully supported 41 | window.queueMicrotask(() => { 42 | if (this.isConnected === false) { 43 | this.#abortController.abort(); 44 | this.#loop.return(); 45 | } 46 | }); 47 | } 48 | 49 | attributeChangedCallback(name, oldValue, newValue) { 50 | if (oldValue !== newValue && this.isConnected) { 51 | this.render(); 52 | } 53 | } 54 | 55 | render(update = {}) { 56 | const currentPendingUpdateCount = this.#updateStack.length; 57 | this.#updateStack.push(update); 58 | if (!currentPendingUpdateCount) { 59 | window.queueMicrotask(() => { 60 | const updatesToProcess = [...this.#updateStack]; 61 | this.#updateStack.length = 0; 62 | const arg = { 63 | attributes: getAttributes(this), 64 | ...Object.assign(...updatesToProcess), 65 | }; 66 | if (this.hasAttribute('debug')) { 67 | console.debug('rendering', arg); 68 | } 69 | this.#loop.next(arg); 70 | }); 71 | } 72 | } 73 | }; 74 | }; 75 | 76 | const getAttributes = (el) => 77 | Object.fromEntries( 78 | el.getAttributeNames().map((name) => [name, el.getAttribute(name)]), 79 | ); 80 | -------------------------------------------------------------------------------- /packages/core/test/reactive-attributes.test.js: -------------------------------------------------------------------------------- 1 | import { test } from '@cofn/test-lib/client'; 2 | import { define } from '../src/index.js'; 3 | import { nextTick } from './utils.js'; 4 | 5 | const debug = document.getElementById('debug'); 6 | const coroutine = function* ({ $root, $host }) { 7 | $host.renderCount = 0; 8 | while (true) { 9 | const { attributes } = yield; 10 | $root.textContent = `${attributes['first-attribute']} - ${attributes['second-attribute']}`; 11 | $host.renderCount += 1; 12 | } 13 | }; 14 | define('static-attributes-component', coroutine); 15 | define('reactive-attributes-component', coroutine, { 16 | observedAttributes: ['first-attribute', 'second-attribute'], 17 | }); 18 | 19 | test('attributes are forwarded as data', async ({ eq }) => { 20 | const el = document.createElement('static-attributes-component'); 21 | el.setAttribute('first-attribute', 'hello'); 22 | el.setAttribute('second-attribute', 'world'); 23 | debug.appendChild(el); 24 | 25 | await nextTick(); 26 | 27 | eq(el.textContent, 'hello - world'); 28 | eq(el.renderCount, 1); 29 | }); 30 | 31 | test('component is not updated when attribute is not declared observed', async ({ 32 | eq, 33 | }) => { 34 | const el = document.createElement('static-attributes-component'); 35 | el.setAttribute('first-attribute', 'hello'); 36 | el.setAttribute('second-attribute', 'world'); 37 | debug.appendChild(el); 38 | await nextTick(); 39 | eq(el.textContent, 'hello - world'); 40 | eq(el.renderCount, 1); 41 | el.setAttribute('first-attribute', 'bonjour'); 42 | await nextTick(); 43 | eq(el.textContent, 'hello - world'); 44 | eq(el.renderCount, 1); 45 | }); 46 | 47 | test('component is updated when attribute is declared observed', async ({ 48 | eq, 49 | }) => { 50 | const el = document.createElement('reactive-attributes-component'); 51 | el.setAttribute('first-attribute', 'hello'); 52 | el.setAttribute('second-attribute', 'world'); 53 | debug.appendChild(el); 54 | await nextTick(); 55 | eq(el.textContent, 'hello - world'); 56 | eq(el.renderCount, 1); 57 | el.setAttribute('first-attribute', 'bonjour'); 58 | 59 | await nextTick(); 60 | 61 | eq(el.textContent, 'bonjour - world'); 62 | eq(el.renderCount, 2); 63 | 64 | el.setAttribute('first-attribute', 'buenas tardes'); 65 | el.setAttribute('second-attribute', 'lorenzo'); 66 | 67 | await nextTick(); 68 | 69 | eq(el.textContent, 'buenas tardes - lorenzo'); 70 | eq( 71 | el.renderCount, 72 | 3, 73 | 'there is only one render when multiple attributes are updated', 74 | ); 75 | }); 76 | -------------------------------------------------------------------------------- /packages/test-lib/src/client/html-reporter/test-result.component.js: -------------------------------------------------------------------------------- 1 | const TestResultTemplate = document.createElement('template'); 2 | TestResultTemplate.innerHTML = ` 3 |
    4 | 14 |

    15 | 16 |
    17 |
    18 | `; 19 | 20 | export class TestResult extends HTMLElement { 21 | static get observedAttributes() { 22 | return ['description']; 23 | } 24 | 25 | #_runTime = undefined; 26 | #_assertions = []; 27 | 28 | get runTime() { 29 | return this.#_runTime !== undefined ? Number(this.#_runTime) : undefined; 30 | } 31 | 32 | get isPassing() { 33 | return this.#_assertions.every(({ pass }) => pass === true); 34 | } 35 | 36 | get description() { 37 | return this.hasAttribute('description') 38 | ? this.getAttribute('description') 39 | : undefined; 40 | } 41 | 42 | set description(value) { 43 | this.setAttribute('description', String(value)); 44 | } 45 | 46 | connectedCallback() { 47 | this.replaceChildren(TestResultTemplate.content.cloneNode(true)); 48 | } 49 | 50 | attributeChangedCallback(name, oldValue, newValue) { 51 | if (oldValue === newValue) { 52 | return; 53 | } 54 | 55 | switch (name) { 56 | case 'description': { 57 | this.querySelector('h2').textContent = newValue; 58 | break; 59 | } 60 | } 61 | } 62 | 63 | endsTest({ executionTime }) { 64 | this.#_runTime = executionTime; 65 | this.querySelector('time').textContent = `${executionTime}ms`; 66 | this.classList.add(this.isPassing ? 'success' : 'failure'); 67 | } 68 | 69 | addAssertion(assertion) { 70 | this.#_assertions.push(assertion); 71 | if (assertion.pass === false) { 72 | const diagnosticEl = document.createElement('zora-diagnostic'); 73 | this.querySelector('section').append(diagnosticEl); 74 | diagnosticEl.diagnostic = assertion; 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /packages/core/readme.md: -------------------------------------------------------------------------------- 1 | # Core 2 | 3 | Core library to build web components from coroutines. 4 | 5 | See the [blog article](https://lorenzofox.dev/posts/component-as-infinite-loop/) for more details. 6 | 7 | ## Installation 8 | 9 | you can install the library with a package manager (like npm): 10 | ``npm install @cofn/core`` 11 | 12 | Or import it directly from a CDN 13 | 14 | ```js 15 | import {define} from 'https://unpkg.com/@cofn/core/dist/cofn-core.js' 16 | ``` 17 | 18 | ## Usage 19 | 20 | The package exports a ``define`` function you can use to define new custom elements 21 | 22 | ```js 23 | import { define } from '@cofn/core'; 24 | 25 | define('hello-world', function* ({ $host, $root, $signal }) { 26 | // constructing 27 | let input = yield 'constructured'; 28 | 29 | // mounted 30 | 31 | try { 32 | while (true) { 33 | $root.textContent = `hello ${input.attributes.name}`; 34 | input = yield; 35 | // update requested 36 | } 37 | } finally { 38 | // the instance is removed from the DOM tree: you won't be able to use it anymore 39 | console.log('clean up') 40 | } 41 | }, { observedAttributes: ['name'] }) 42 | 43 | // 44 | ``` 45 | 46 | The component is defined as a generator function which has injected: 47 | * ``$host``: the custom element instance 48 | * ``$root``: the DOM tree root of the custom element instance. It is either the ``$host`` itself or the ``shadowRoot`` if you have passed shadow dom option in the third optional argument 49 | * ``$signal``: an ``AbortSignal`` which is triggered when the element is unmounted. This is more for convenience, to ease the cleanup, if you use APIs which can take an abort signal as option. Otherwise, you can run clean up code in the ``finally`` clause. 50 | 51 | Because generators are functions, you can use higher order functions and delegation to enhance the custom element behaviour. You will find more details in the [blog](https://lorenzofox.dev) 52 | 53 | By default, when the generator yields, the attributes of the custom elements are assigned under the ``attributes`` namespace. 54 | 55 | ### options 56 | 57 | The third argument is optional. It takes the same parameters as the regular [customElementRegistry.define](https://developer.mozilla.org/en-US/docs/Web/API/CustomElementRegistry/define) and some more: 58 | * ``extends``: If you wish to make your custom element extends a built-in element. Careful, webkit refuses to implement that spec and you will need a [polyfill](https://unpkg.com/@ungap/custom-elements@1.3.0/es.js) 59 | * ``shadow``: same as [attachShadow](https://developer.mozilla.org/en-US/docs/Web/API/Element/attachShadow) function. 60 | * ``observedAttributes``: the list of attributes the browser will observe. Any change on the one of these attributes will resume the generator execution. 61 | -------------------------------------------------------------------------------- /apps/restaurant-cashier/cart/cart.component.js: -------------------------------------------------------------------------------- 1 | const itemQuantityFromEvent = (ev) => { 2 | const nodes = ev.composedPath(); 3 | const skuEl = nodes.find((el) => el.dataset?.id !== undefined); 4 | const actionEl = nodes.find((el) => el.dataset?.action !== undefined); 5 | if (!skuEl || !actionEl) { 6 | return undefined; 7 | } 8 | 9 | const { quantity, id: sku } = skuEl.dataset; 10 | const { action } = actionEl.dataset; 11 | return { 12 | sku, 13 | quantity: Number(quantity) + (action === 'increment' ? 1 : -1), 14 | }; 15 | }; 16 | 17 | export const Cart = ({ html, cartService, $host }) => { 18 | setTimeout(cartService.fetchCurrent, 200); // todo 19 | 20 | $host.addEventListener('click', (ev) => { 21 | ev.stopPropagation(); 22 | const itemQuantity = itemQuantityFromEvent(ev); 23 | if (itemQuantity) { 24 | cartService.setItemQuantity(itemQuantity); 25 | } 26 | }); 27 | 28 | return ({ currentCart, products }) => { 29 | const cartProducts = Object.entries(currentCart.items).map( 30 | ([sku, cartItem]) => ({ 31 | ...products[sku], 32 | ...cartItem, 33 | }), 34 | ); 35 | 36 | const hasItem = cartProducts.length > 0; 37 | 38 | return html`

    Your cart

    39 |
      40 | ${cartProducts.map(({ title, sku, quantity, price }) => { 41 | return html`${'cart-item-' + sku}:: 42 |
    • 43 |
      44 | ${title}ref - ${sku} 45 |
      46 |
      47 | 51 | ${quantity} 52 | 56 |
      57 | ${price.amountInCents / 100 + price.currency} 58 |
    • `; 59 | })} 60 |
    61 | ${hasItem 62 | ? html`

    63 | For a total of 64 | ${currentCart.total.amountInCents / 100 + 66 | currentCart.total.currency} 67 | 68 |

    ` 69 | : html`

    cart is currently empty

    `} 70 |
    71 | 75 | 79 |
    `; 80 | }; 81 | }; 82 | -------------------------------------------------------------------------------- /apps/restaurant-cashier/users/preferences.service.js: -------------------------------------------------------------------------------- 1 | import { createEventEmitter } from '../utils/events.service.js'; 2 | import { matchMedia } from '../utils/dom.js'; 3 | 4 | export const motionSettings = { 5 | default: 'default', 6 | reduced: 'reduced', 7 | normal: 'normal', 8 | }; 9 | 10 | export const themeSettings = { 11 | default: 'default', 12 | light: 'light', 13 | dark: 'dark', 14 | }; 15 | 16 | export const preferencesEvents = { 17 | PREFERENCES_CHANGED: 'preferences-changed', 18 | }; 19 | 20 | const preferencesStorageKey = 'preferences'; 21 | const colorSchemeMedia = matchMedia('(prefers-color-scheme: dark)'); 22 | const reducedMotionMedia = matchMedia('(prefers-reduced-motion: reduce)'); 23 | export const createPreferencesService = ({ storageService }) => { 24 | colorSchemeMedia.addEventListener('change', mediaQueryChangeHandler); 25 | reducedMotionMedia.addEventListener('change', mediaQueryChangeHandler); 26 | 27 | let state = { 28 | theme: themeSettings.default, 29 | motion: motionSettings.default, 30 | }; 31 | 32 | const service = createEventEmitter(); 33 | const emit = () => 34 | service.emit({ type: preferencesEvents.PREFERENCES_CHANGED }); 35 | 36 | // init 37 | storageService.getItem(preferencesStorageKey).then((settings) => { 38 | if (settings) { 39 | state = JSON.parse(settings); 40 | emit(); 41 | } 42 | }); 43 | 44 | return Object.assign(service, { 45 | getState() { 46 | return structuredClone({ 47 | theme: { 48 | value: state.theme, 49 | computed: 50 | state.theme !== themeSettings.default 51 | ? state.theme 52 | : fromMediaQueries().theme, 53 | }, 54 | motion: { 55 | value: state.motion, 56 | computed: 57 | state.motion !== motionSettings.default 58 | ? state.motion 59 | : fromMediaQueries().motion, 60 | }, 61 | }); 62 | }, 63 | changeTheme: withDispatch((value) => { 64 | state.theme = themeSettings[value] ?? state.theme; 65 | }), 66 | changeMotion: withDispatch((value) => { 67 | state.motion = motionSettings[value] ?? state.motion; 68 | }), 69 | }); 70 | async function mediaQueryChangeHandler() { 71 | if ( 72 | state.theme === themeSettings.default || 73 | state.motion === motionSettings.default 74 | ) { 75 | emit(); 76 | } 77 | } 78 | 79 | function withDispatch(method) { 80 | return async (...args) => { 81 | await method(...args); 82 | emit(); 83 | storageService.setItem(preferencesStorageKey, JSON.stringify(state)); 84 | }; 85 | } 86 | }; 87 | function fromMediaQueries() { 88 | return { 89 | theme: colorSchemeMedia.matches ? themeSettings.dark : themeSettings.light, 90 | motion: reducedMotionMedia.matches 91 | ? motionSettings.reduced 92 | : motionSettings.normal, 93 | }; 94 | } 95 | -------------------------------------------------------------------------------- /apps/restaurant-cashier/users/preferences.component.js: -------------------------------------------------------------------------------- 1 | import { motionSettings, themeSettings } from './preferences.service.js'; 2 | 3 | export const PreferencesComponent = ({ html, preferencesService }) => { 4 | const handleThemeChange = ({ target: { value } }) => 5 | preferencesService.changeTheme(value); 6 | const handleMotionChange = ({ target: { value } }) => 7 | preferencesService.changeMotion(value); 8 | 9 | return ({ motion, theme }) => { 10 | const { value: motionValue } = motion; 11 | const { value: themeValue } = theme; 12 | return html`
    16 | Theme 17 |
    18 | 27 | 36 | 45 |
    46 |
    47 |
    48 | Motion 49 |
    50 | 59 | 68 | 77 |
    78 |
    `; 79 | }; 80 | }; 81 | -------------------------------------------------------------------------------- /apps/restaurant-cashier/cart/cart.service.js: -------------------------------------------------------------------------------- 1 | import { createEventEmitter } from '../utils/events.service.js'; 2 | import { http } from '../utils/http.js'; 3 | import { notificationsService } from '../utils/notifications.service.js'; 4 | import { 5 | productListEvents, 6 | productListService, 7 | } from '../products/product-list.service.js'; 8 | import { keyBy } from '../utils/objects.js'; 9 | 10 | export const cartEvents = { 11 | CART_CHANGED: 'cart-changed', 12 | }; 13 | export const createCartService = ({ 14 | notificationsService, 15 | productListService, 16 | }) => { 17 | const store = { 18 | currentCart: { 19 | id: 'test', 20 | items: {}, 21 | createdAt: new Date(), 22 | total: { 23 | amountInCents: 0, 24 | currency: '$', 25 | }, 26 | }, 27 | products: {}, 28 | }; 29 | 30 | const service = createEventEmitter(); 31 | 32 | productListService.on(productListEvents.PRODUCT_LIST_CHANGED, () => { 33 | store.products = keyBySKU(productListService.getState().products); 34 | }); 35 | 36 | return Object.assign(service, { 37 | async fetchCurrent() { 38 | store.currentCart = await http('carts/current'); 39 | await productListService.fetch(); 40 | service.emit({ 41 | type: 'cart-changed', 42 | }); 43 | }, 44 | async setItemQuantity({ sku, quantity = 1 }) { 45 | const currentItem = store.currentCart.items[sku]; 46 | 47 | store.currentCart.items = normalizeCartItems({ 48 | items: { 49 | ...store.currentCart.items, 50 | [sku]: { 51 | quantity, 52 | }, 53 | }, 54 | }); 55 | 56 | service.emit(cartEvents.CART_CHANGED); 57 | 58 | try { 59 | await http(`carts/${store.currentCart.id}/${sku}`, { 60 | method: 'PUT', 61 | body: JSON.stringify({ 62 | quantity, 63 | }), 64 | }); 65 | await service.fetchCurrent(); 66 | } catch (err) { 67 | notificationsService.error({ 68 | message: 'An error occurred. Cart Item could not be changed', 69 | }); 70 | store.currentCart.items = normalizeCartItems({ 71 | items: { 72 | ...store.currentCart.items, 73 | [sku]: currentItem, 74 | }, 75 | }); 76 | service.emit({ 77 | type: cartEvents.CART_CHANGED, 78 | }); 79 | } 80 | }, 81 | getState() { 82 | return structuredClone(store); 83 | }, 84 | }); 85 | }; 86 | 87 | const normalizeCartItems = ({ items }) => { 88 | return Object.fromEntries( 89 | Object.entries(items) 90 | .filter(([, item]) => (item?.quantity ?? 0) > 0) 91 | .map(([sku, item]) => [sku, item]), 92 | ); 93 | }; 94 | 95 | const keyBySKU = keyBy(({ sku }) => sku); 96 | 97 | export const cartService = createCartService({ 98 | notificationsService, 99 | productListService, 100 | }); 101 | -------------------------------------------------------------------------------- /apps/restaurant-cashier/router/router.js: -------------------------------------------------------------------------------- 1 | import { composeStack, createContext } from './utils.js'; 2 | import { createTrie } from './trie.js'; 3 | import { createEventEmitter } from '../utils/events.service.js'; 4 | 5 | export const navigationEvents = { 6 | ROUTE_CHANGE_STARTED: 'ROUTE_CHANGE_STARTED', 7 | ROUTE_CHANGE_SUCCEEDED: 'ROUTE_CHANGE_SUCCEEDED', 8 | ROUTE_CHANGE_FAILED: 'ROUTE_CHANGE_FAILED', 9 | PAGE_LOADED: 'PAGE_LOADED', 10 | }; 11 | 12 | export const createRouter = ({ global = window } = {}) => { 13 | let notFoundHandler = global.console.error; 14 | const origin = global.location.origin; 15 | const trie = createTrie(); 16 | const routes = {}; 17 | 18 | const service = Object.assign(createEventEmitter(), { 19 | goTo(route, data = {}) { 20 | const newURL = new URL(route, origin); 21 | const state = { ...data, navigation: { URL: newURL.href } }; 22 | global.history.pushState(state, '', newURL.href); 23 | global.dispatchEvent(new PopStateEvent('popstate', { state })); 24 | }, 25 | redirect(route, data = {}) { 26 | const newURL = new URL(route, origin); 27 | const state = { ...data, navigation: { URL: newURL.href } }; 28 | global.history.replaceState(state, '', newURL.href); 29 | global.dispatchEvent(new PopStateEvent('popstate', { state })); 30 | }, 31 | addRoute(routeDef, stack = []) { 32 | trie.insert(routeDef.pattern); 33 | routes[routeDef.pattern] = { 34 | ...routeDef, 35 | handler: composeStack([...stack, emitSuccess]), 36 | }; 37 | return this; 38 | }, 39 | notFound(fn) { 40 | notFoundHandler = fn; 41 | return this; 42 | }, 43 | }); 44 | 45 | global.addEventListener('popstate', handlePopState); 46 | 47 | return Object.create(service, { 48 | origin: { value: origin, enumerable: true }, 49 | }); 50 | 51 | async function handlePopState({ state = {} }) { 52 | try { 53 | const navigation = state.navigation ?? {}; 54 | const requestedURL = navigation.URL ?? origin; 55 | const pathName = new URL(requestedURL).pathname; 56 | 57 | service.emit(navigationEvents.ROUTE_CHANGE_STARTED, { 58 | requestedURL, 59 | state, 60 | }); 61 | 62 | const { match, params } = trie.search(pathName); 63 | const routeDef = routes?.[match] ?? { handler: notFoundHandler }; 64 | const context = createContext({ 65 | state: { 66 | ...state, 67 | navigation: { 68 | ...navigation, 69 | params, 70 | }, 71 | }, 72 | routeDef, 73 | router: service, 74 | }); 75 | await routeDef.handler(context); 76 | } catch (error) { 77 | service.emit(navigationEvents.ROUTE_CHANGE_FAILED, { 78 | error, 79 | }); 80 | } 81 | } 82 | }; 83 | 84 | function emitSuccess(ctx) { 85 | const { state, router } = ctx; 86 | router.emit({ 87 | type: navigationEvents.ROUTE_CHANGE_SUCCEEDED, 88 | detail: { 89 | requestedURL: state?.navigation?.URL, 90 | state, 91 | }, 92 | }); 93 | } 94 | -------------------------------------------------------------------------------- /apps/restaurant-cashier/theme/form.css: -------------------------------------------------------------------------------- 1 | form { 2 | --_spacing: var(--spacing, var(--spacing-small, 1em)); 3 | --_adorner-size: 3px; 4 | 5 | --_border-color: var(--form-border-color, gray); 6 | --_font-color: var(--font-color-lighter); 7 | --_background-fill: var(--control-focus-color); 8 | --_adorner-color: var(--action-color); 9 | padding-block: 1em; 10 | } 11 | 12 | label { 13 | font-size: 0.85em; 14 | display: flex; 15 | flex-direction: column; 16 | gap: calc(var(--_spacing) / 4); 17 | overflow: hidden; 18 | 19 | > span:first-child::first-letter { 20 | text-transform: uppercase; 21 | } 22 | 23 | > span:first-child { 24 | align-self: flex-start; 25 | width: min-content; /* looks like there is a bug in FF */ 26 | background-image: linear-gradient(90deg, var(--_adorner-color), var(--_adorner-color)); 27 | background-size: 0 var(--_adorner-size); 28 | background-repeat: no-repeat; 29 | background-position: left bottom; 30 | } 31 | 32 | &:focus-within > span:first-child { 33 | background-size: 100% 3px; 34 | } 35 | } 36 | 37 | :disabled { 38 | opacity: 0.4; 39 | } 40 | 41 | 42 | .input-error { 43 | color: var(--danger-color); 44 | line-height: 1; 45 | font-size: 0.75rem; 46 | padding-block-end: 0.3rem; 47 | transform: scaleY(0); 48 | transform-origin: 0 0; 49 | transition: transform var(--animation-duration); 50 | 51 | &.active { 52 | transform: scaleY(1); 53 | } 54 | } 55 | 56 | input, textarea { 57 | color: var(--_font-color); 58 | padding: 0.3em; 59 | outline: none; 60 | border: 1px solid var(--_border-color); 61 | border-radius: var(--border-radius); 62 | background-color: inherit; 63 | background-image: linear-gradient(90deg, var(--_background-fill), var(--_background-fill)), 64 | linear-gradient(90deg, var(--_adorner-color), var(--_adorner-color)); 65 | background-size: 0 calc(100% - var(--_adorner-size)), 0 var(--_adorner-size); 66 | background-repeat: no-repeat, no-repeat; 67 | background-position: top left, left bottom; 68 | transition: background-size var(--animation-duration); 69 | font-family: inherit; 70 | font-size: 0.85em; 71 | 72 | &:has(+ .input-error.active) { 73 | --_border-color: var(--danger-color); 74 | --_adorner-color: var(--danger-color); 75 | } 76 | 77 | &:focus { 78 | background-size: 100% calc(100% - var(--_adorner-size)), 100% var(--_adorner-size); 79 | --_border-color: var(--action-color); 80 | --_font-color: var(--font-color-focus, var(--font-color-lighter)); 81 | } 82 | } 83 | 84 | textarea { 85 | resize: none; 86 | } 87 | 88 | input, textarea, .radio-group { 89 | box-shadow: 0 0 2px 0 var(--shadow-color); 90 | } 91 | 92 | .action-bar { 93 | display: flex; 94 | gap: var(--_spacing); 95 | justify-content: flex-end; 96 | } 97 | 98 | fieldset { 99 | border: unset; 100 | padding: unset; 101 | } 102 | -------------------------------------------------------------------------------- /packages/di/test/injector.test.js: -------------------------------------------------------------------------------- 1 | import { test } from '@cofn/test-lib/client'; 2 | import { createInjector } from '../src/injector.js'; 3 | 4 | test('instantiates an injectable, calling the factory', ({ eq }) => { 5 | const { a } = createInjector({ 6 | services: { 7 | a: () => 'a', 8 | }, 9 | }); 10 | 11 | eq(a, 'a'); 12 | }); 13 | 14 | test('instantiates an injectable, when it is a value', ({ eq }) => { 15 | const { a } = createInjector({ 16 | services: { 17 | a: 'a', 18 | }, 19 | }); 20 | 21 | eq(a, 'a'); 22 | }); 23 | 24 | test('everytime the getter is called a new instance is created', ({ 25 | eq, 26 | isNot, 27 | }) => { 28 | const services = createInjector({ 29 | services: { 30 | a: () => ({ prop: 'a' }), 31 | }, 32 | }); 33 | const instance1 = services.a; 34 | const { a: instance2 } = services; 35 | eq(instance1, { prop: 'a' }); 36 | eq(instance2, { prop: 'a' }); 37 | isNot(instance2, instance1); 38 | }); 39 | 40 | test('resolves dependency graph, instantiating the transitive dependencies ', ({ 41 | eq, 42 | }) => { 43 | const services = createInjector({ 44 | services: { 45 | a: ({ b, c }) => b + '+' + c, 46 | b: () => 'b', 47 | c: ({ d }) => d, 48 | d: 'd', 49 | }, 50 | }); 51 | eq(services.a, 'b+d'); 52 | }); 53 | 54 | test('injection tokens can be symbols', ({ eq }) => { 55 | const aSymbol = Symbol('a'); 56 | const bSymbol = Symbol('b'); 57 | const cSymbol = Symbol('c'); 58 | const dSymbol = Symbol('d'); 59 | 60 | const services = createInjector({ 61 | services: { 62 | [aSymbol]: ({ [bSymbol]: b, [cSymbol]: c }) => b + '+' + c, 63 | [bSymbol]: () => 'b', 64 | [cSymbol]: ({ [dSymbol]: d }) => d, 65 | [dSymbol]: 'd', 66 | }, 67 | }); 68 | eq(services[aSymbol], 'b+d'); 69 | }); 70 | 71 | test(`only instantiates an injectable when required`, ({ eq, notOk, ok }) => { 72 | let aInstantiated = false; 73 | let bInstantiated = false; 74 | let cInstantiated = false; 75 | 76 | const services = createInjector({ 77 | services: { 78 | a: ({ b }) => { 79 | aInstantiated = true; 80 | return b; 81 | }, 82 | b: () => { 83 | bInstantiated = true; 84 | return 'b'; 85 | }, 86 | c: () => { 87 | cInstantiated = true; 88 | return 'c'; 89 | }, 90 | }, 91 | }); 92 | 93 | const { a } = services; 94 | 95 | eq(a, 'b'); 96 | ok(aInstantiated); 97 | ok(bInstantiated); 98 | notOk(cInstantiated); 99 | 100 | const { c } = services; 101 | eq(c, 'c'); 102 | ok(cInstantiated); 103 | }); 104 | 105 | test('gives a friendly message when it can not resolve a dependency', ({ 106 | eq, 107 | fail, 108 | }) => { 109 | const services = createInjector({ 110 | services: { 111 | a: ({ b }) => b, 112 | b: ({ c }) => c, 113 | }, 114 | }); 115 | 116 | try { 117 | const { a } = services; 118 | fail('should not reach that statement'); 119 | } catch (err) { 120 | eq(err.message, 'could not resolve injectable "c"'); 121 | } 122 | }); 123 | -------------------------------------------------------------------------------- /apps/restaurant-cashier/products/product-list.service.js: -------------------------------------------------------------------------------- 1 | import { createEventEmitter } from '../utils/events.service.js'; 2 | import { http } from '../utils/http.js'; 3 | import { notificationsService } from '../utils/notifications.service.js'; 4 | 5 | export const productListEvents = { 6 | PRODUCT_LIST_CHANGED: 'product-list-changed', 7 | }; 8 | 9 | export const createProductListService = ({ notificationsService }) => { 10 | const store = { 11 | products: { 12 | items: {}, 13 | }, 14 | }; 15 | const service = createEventEmitter(); 16 | const dispatch = () => 17 | service.emit({ 18 | type: productListEvents.PRODUCT_LIST_CHANGED, 19 | }); 20 | 21 | return Object.assign(service, { 22 | async fetch() { 23 | store.products.items = await http('products'); 24 | dispatch(); 25 | }, 26 | async remove({ sku }) { 27 | const toRemove = store.products.items[sku]; 28 | delete store.products.items[sku]; 29 | // optimistic update: we do not wait for the result 30 | dispatch(); 31 | try { 32 | return await http(`products/${sku}`, { 33 | method: 'DELETE', 34 | }); 35 | } catch (err) { 36 | notificationsService.error({ 37 | message: 'An error occurred. The product could not be deleted.', 38 | }); 39 | store.products.items[sku] = toRemove; 40 | dispatch(); 41 | } 42 | }, 43 | async fetchOne({ sku }) { 44 | const product = await http(`products/${sku}`, { 45 | method: 'GET', 46 | }); 47 | return (store.products.items[product.sku] = product); 48 | }, 49 | async update({ product }) { 50 | const oldValue = store.products.items[product.sku]; 51 | store.products.items[product.sku] = product; 52 | // optimistic update: we do not wait for the result 53 | dispatch(); 54 | try { 55 | return await http(`products/${product.sku}`, { 56 | method: 'PUT', 57 | body: JSON.stringify(product), 58 | }); 59 | } catch (err) { 60 | notificationsService.error({ 61 | message: 'An error occurred. The product could not be updated.', 62 | }); 63 | store.products.items[product.sku] = oldValue; 64 | dispatch(); 65 | } 66 | }, 67 | async create({ product }) { 68 | store.products.items[product.sku] = product; 69 | // optimistic update: we do not wait for the result 70 | dispatch(); 71 | try { 72 | return await http(`products`, { 73 | method: 'POST', 74 | body: JSON.stringify(product), 75 | }); 76 | } catch (err) { 77 | notificationsService.error({ 78 | message: 'An error occurred. The product could not be created.', 79 | }); 80 | delete store.products.items[product.sku]; 81 | dispatch(); 82 | } 83 | }, 84 | getState() { 85 | return structuredClone({ 86 | products: Object.entries(store.products.items ?? {}).map( 87 | ([sku, product]) => ({ 88 | sku, 89 | ...product, 90 | }), 91 | ), 92 | }); 93 | }, 94 | }); 95 | }; 96 | 97 | export const productListService = createProductListService({ 98 | notificationsService, 99 | }); 100 | -------------------------------------------------------------------------------- /packages/controllers/test/controller.js: -------------------------------------------------------------------------------- 1 | import { test } from '@cofn/test-lib/client'; 2 | import { withController } from '../src/index.js'; 3 | import { define } from '@cofn/core'; 4 | import { nextTick } from './utils.js'; 5 | 6 | const debug = document.getElementById('debug'); 7 | const withCounterController = withController(({ state }) => { 8 | state.count = 42; 9 | return { 10 | increment() { 11 | state.count = state.count + 1; 12 | }, 13 | }; 14 | }); 15 | 16 | define( 17 | 'test-counting-controller', 18 | withCounterController(function* ({ $host, controller }) { 19 | $host.addEventListener('click', controller.increment); 20 | $host.loopCount = 0; 21 | 22 | try { 23 | while (true) { 24 | const { state } = yield; 25 | $host.textContent = 'state:' + state.count; 26 | $host.loopCount += 1; 27 | } 28 | } finally { 29 | $host.teardown = true; 30 | } 31 | }), 32 | ); 33 | const withCounter = (specFn) => { 34 | return async function zora_spec_fn(assert) { 35 | const el = document.createElement('test-counting-controller'); 36 | debug.appendChild(el); 37 | return await specFn({ ...assert, el }); 38 | }; 39 | }; 40 | 41 | test('controller function get passed the routine dependencies along with the state', async ({ 42 | eq, 43 | ok, 44 | }) => { 45 | let hasBeenChecked = false; 46 | const withSimpleController = withController((deps) => { 47 | ok(deps.$signal); 48 | ok(deps.$host); 49 | ok(deps.$root); 50 | ok(deps.state); 51 | hasBeenChecked = true; 52 | return {}; 53 | }); 54 | 55 | define( 56 | 'simple-controller', 57 | withSimpleController(function* ({ $host }) { 58 | while (true) { 59 | yield; 60 | } 61 | }), 62 | ); 63 | 64 | const el = document.createElement('simple-controller'); 65 | 66 | debug.appendChild(el); 67 | 68 | await nextTick(); 69 | 70 | eq(hasBeenChecked, true); 71 | }); 72 | 73 | test( 74 | 'component is rendered with the initial state set by the controller', 75 | withCounter(async ({ eq, el }) => { 76 | await nextTick(); 77 | eq(el.textContent, 'state:42'); 78 | eq(el.loopCount, 1); 79 | }), 80 | ); 81 | 82 | test( 83 | 'when state is updated by the controller, the component is rendered', 84 | withCounter(async ({ eq, el }) => { 85 | await nextTick(); 86 | eq(el.textContent, 'state:42'); 87 | el.click(); 88 | await nextTick(); 89 | eq(el.textContent, 'state:43'); 90 | el.click(); 91 | await nextTick(); 92 | eq(el.textContent, 'state:44'); 93 | eq(el.loopCount, 3); 94 | }), 95 | ); 96 | 97 | test( 98 | 'updates are batched together', 99 | withCounter(async ({ el, eq }) => { 100 | await nextTick(); 101 | eq(el.textContent, 'state:42'); 102 | eq(el.loopCount, 1); 103 | el.click(); 104 | el.click(); 105 | el.click(); 106 | eq(el.textContent, 'state:42'); 107 | await nextTick(); 108 | eq(el.textContent, 'state:45'); 109 | eq(el.loopCount, 2); 110 | }), 111 | ); 112 | 113 | test( 114 | 'tears down of the component is called', 115 | withCounter(async ({ ok, notOk, el }) => { 116 | notOk(el.teardown); 117 | el.remove(); 118 | await nextTick(); 119 | ok(el.teardown); 120 | }), 121 | ); 122 | -------------------------------------------------------------------------------- /packages/test-lib/src/client/theme.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --flag-success-color: #1a751a; 3 | --flag-failure-color: #ef4d4d; 4 | --code-block-bg-color: #ececec; 5 | --spacing: 0.85em; 6 | } 7 | 8 | @media (prefers-color-scheme: dark) { 9 | :root { 10 | --code-block-bg-color: #313044; 11 | } 12 | } 13 | 14 | html { 15 | color-scheme: dark light; 16 | padding: 0; 17 | margin: 0; 18 | } 19 | 20 | 21 | body { 22 | margin: 0; 23 | box-sizing: border-box; 24 | } 25 | 26 | body * { 27 | box-sizing: inherit; 28 | } 29 | 30 | #report-container { 31 | min-height: 100svh; 32 | font-family: system-ui, sans-serif; 33 | font-size: 1.125rem; 34 | width: min(60ch, 100% - 4rem); 35 | margin-inline: auto; 36 | margin-block-end: 4rem; 37 | } 38 | 39 | #report { 40 | display: grid; 41 | grid-auto-flow: row; 42 | gap: 0.5em; 43 | } 44 | 45 | zora-test-result { 46 | border: 1px solid currentColor; 47 | border-radius: 0 4px 4px 0; 48 | } 49 | 50 | zora-test-result h2 { 51 | font-size: 1em; 52 | font-weight: 500; 53 | margin: 0; 54 | flex-grow: 1; 55 | } 56 | 57 | zora-test-result time { 58 | font-size: 0.8em; 59 | } 60 | 61 | zora-test-result header { 62 | --_gap-size: var(--spacing, 1em); 63 | display: flex; 64 | align-items: center; 65 | gap: var(--_gap-size); 66 | padding-inline-end: var(--_gap-size); 67 | } 68 | 69 | zora-test-result svg { 70 | visibility: hidden; 71 | grid-row: 1; 72 | grid-column: 1; 73 | fill: whitesmoke; 74 | } 75 | 76 | zora-test-result section { 77 | --_spacing: var(--spacing, 1em); 78 | padding: var(--_spacing); 79 | border-top: 1px solid currentColor; 80 | } 81 | 82 | zora-test-result section:empty{ 83 | display: none; 84 | } 85 | 86 | zora-test-result.success .icon-success { 87 | visibility: visible; 88 | } 89 | 90 | zora-test-result.failure .icon-failure { 91 | visibility: visible; 92 | } 93 | 94 | zora-test-result .icon-container{ 95 | --_spacing: var(--spacing, 1em); 96 | display: grid; 97 | place-items: center; 98 | padding: var(--_spacing); 99 | background: var(--_flag-color); 100 | } 101 | 102 | zora-test-result.success .icon-container { 103 | --_flag-color: var(--flag-success-color); 104 | } 105 | 106 | zora-test-result.failure .icon-container { 107 | --_flag-color: var(--flag-failure-color); 108 | } 109 | 110 | zora-diagnostic { 111 | font-size: 0.9em; 112 | } 113 | 114 | zora-diagnostic pre { 115 | --_spacing: var(--spacing, 1em); 116 | --_bg-color: var(--code-block-bg-color, #ececec); 117 | font-size: 0.8em; 118 | padding: var(--_spacing); 119 | background: var(--_bg-color); 120 | border-radius: 4px; 121 | box-shadow: 0 0 2px 0 color-mix(in srgb, black 70%, var(--_bg-color)) inset; 122 | overflow: scroll; 123 | flex-grow: 1; 124 | } 125 | 126 | zora-diagnostic a { 127 | font-size: 0.9em; 128 | } 129 | 130 | zora-diagnostic .comparison-container{ 131 | --_spacing: var(--spacing, 1em); 132 | display: flex; 133 | flex-wrap: wrap; 134 | gap: var(--_spacing); 135 | padding-block: var(--_spacing); 136 | } 137 | 138 | zora-diagnostic figure { 139 | margin: 0; 140 | flex-grow: 1; 141 | flex-basis: calc((100% - var(--_spacing)) / 2); 142 | display: flex; 143 | flex-direction: column; 144 | } 145 | -------------------------------------------------------------------------------- /apps/restaurant-cashier/products/new/new-product.page.js: -------------------------------------------------------------------------------- 1 | import { createElement } from '../../utils/dom.js'; 2 | import { productListService } from '../product-list.service.js'; 3 | import { fromForm, productSkus } from '../product-list.model.js'; 4 | import { define } from '@cofn/core'; 5 | import { ImageUploader } from '../image-uploader/image-uploader.component.js'; 6 | 7 | const template = createElement('template'); 8 | 9 | template.innerHTML = ` 10 |

    Add new product

    11 |
    12 |
    13 | 17 | 21 | 25 | 29 | 33 | 34 |
    35 | 36 | cancel 37 | 38 | 41 |
    42 |
    43 |
    `; 44 | export const loadPage = async ({ router }) => { 45 | define('app-image-uploader', ImageUploader, { 46 | observedAttributes: ['url', 'status'], 47 | shadow: { 48 | mode: 'open', 49 | delegatesFocus: true, 50 | }, 51 | }); 52 | let skus = productSkus(productListService.getState()); 53 | 54 | // Eventually load new items 55 | productListService.fetch().then(() => { 56 | skus = productSkus(productListService.getState()); 57 | }); 58 | 59 | const handleSubmit = async (ev) => { 60 | ev.preventDefault(); 61 | const { target: form } = ev; 62 | const { sku: skuEl } = form.elements; 63 | const isSKUAlreadyUsed = skus.includes(skuEl.value); 64 | skuEl.setCustomValidity( 65 | isSKUAlreadyUsed 66 | ? `SKU should be unique but "${skuEl.value.toUpperCase()}" is already used` 67 | : '', 68 | ); 69 | 70 | if (!form.checkValidity()) { 71 | return; 72 | } 73 | 74 | form.disabled = true; 75 | const product = fromForm(form); 76 | productListService.create({ product }); 77 | form.parentElement.classList.toggle('transition-card-expand'); 78 | form.parentElement.classList.toggle('transition-card-collapse'); 79 | router.goTo('products', product); 80 | }; 81 | const page = template.content.cloneNode(true); 82 | const form = page.querySelector('form'); 83 | form.addEventListener('submit', handleSubmit); 84 | form 85 | .querySelector('app-image-uploader') 86 | .addEventListener( 87 | 'image-uploaded', 88 | ({ detail }) => (form.querySelector('[name=image]').value = detail.url), 89 | ); 90 | return { 91 | title: 'New product', 92 | content: page, 93 | }; 94 | }; 95 | -------------------------------------------------------------------------------- /packages/controllers/test/props.js: -------------------------------------------------------------------------------- 1 | import { test } from '@cofn/test-lib/client'; 2 | import { withProps } from '../src/index.js'; 3 | import { define } from '@cofn/core'; 4 | import { nextTick } from './utils.js'; 5 | 6 | const debug = document.getElementById('debug'); 7 | const withTestProps = withProps(['test', 'other']); 8 | 9 | define( 10 | 'test-props-controller', 11 | withTestProps(function* ({ $host }) { 12 | let loopCount = 0; 13 | Object.defineProperty($host, 'count', { 14 | get() { 15 | return loopCount; 16 | }, 17 | }); 18 | try { 19 | while (true) { 20 | const { properties } = yield; 21 | loopCount += 1; 22 | $host.textContent = JSON.stringify(properties); 23 | } 24 | } finally { 25 | $host.teardown = true; 26 | } 27 | }), 28 | ); 29 | 30 | const withEl = (specFn) => 31 | async function zora_spec_fn(assert) { 32 | const el = document.createElement('test-props-controller'); 33 | debug.appendChild(el); 34 | try { 35 | await specFn({ ...assert, el }); 36 | } catch (err) { 37 | console.log(err); 38 | throw err; 39 | } 40 | }; 41 | 42 | test( 43 | 'component is rendered with initial set properties', 44 | withEl(async ({ eq }) => { 45 | const el = document.createElement('test-props-controller'); 46 | el.test = 'foo'; 47 | await nextTick(); 48 | eq(el.textContent, JSON.stringify({ test: 'foo' })); 49 | }), 50 | ); 51 | 52 | test( 53 | 'component is updated when a property is set', 54 | withEl(async ({ eq, el }) => { 55 | el.test = 'foo'; 56 | el.other = 'blah'; 57 | await nextTick(); 58 | eq(el.textContent, JSON.stringify({ test: 'foo', other: 'blah' })); 59 | el.test = 42; 60 | await nextTick(); 61 | eq(el.textContent, JSON.stringify({ test: 42, other: 'blah' })); 62 | }), 63 | ); 64 | 65 | test( 66 | 'component is updated when a property is set', 67 | withEl(async ({ eq, el }) => { 68 | el.test = 'foo'; 69 | el.other = 'blah'; 70 | await nextTick(); 71 | eq(el.count, 1); 72 | eq(el.textContent, JSON.stringify({ test: 'foo', other: 'blah' })); 73 | el.test = 42; 74 | await nextTick(); 75 | eq(el.count, 2); 76 | eq(el.textContent, JSON.stringify({ test: 42, other: 'blah' })); 77 | }), 78 | ); 79 | 80 | test( 81 | 'component is updated once when several properties are set', 82 | withEl(async ({ eq, el }) => { 83 | el.test = 'foo'; 84 | el.other = 'blah'; 85 | await nextTick(); 86 | eq(el.count, 1); 87 | eq(el.textContent, JSON.stringify({ test: 'foo', other: 'blah' })); 88 | el.test = 42; 89 | el.other = 'updated'; 90 | await nextTick(); 91 | eq(el.count, 2); 92 | eq(el.textContent, JSON.stringify({ test: 42, other: 'updated' })); 93 | }), 94 | ); 95 | 96 | test( 97 | 'component is not updated when a property is set but the property is not in the reactive property list', 98 | withEl(async ({ eq, el }) => { 99 | el.test = 'foo'; 100 | el.other = 'blah'; 101 | await nextTick(); 102 | eq(el.count, 1); 103 | eq(el.textContent, JSON.stringify({ test: 'foo', other: 'blah' })); 104 | el.whatever = 42; 105 | await nextTick(); 106 | eq(el.count, 1); 107 | eq(el.textContent, JSON.stringify({ test: 'foo', other: 'blah' })); 108 | }), 109 | ); 110 | 111 | test( 112 | 'tears down of the component is called', 113 | withEl(async ({ ok, notOk, el }) => { 114 | notOk(el.teardown); 115 | el.remove(); 116 | await nextTick(); 117 | ok(el.teardown); 118 | }), 119 | ); 120 | -------------------------------------------------------------------------------- /packages/view/test/simple-render.test.js: -------------------------------------------------------------------------------- 1 | import { test } from '@cofn/test-lib/client'; 2 | import { fromView, nextTick } from './utils.js'; 3 | 4 | const debug = document.getElementById('debug'); 5 | test('renders a component when mounted', async ({ eq }) => { 6 | const el = fromView( 7 | ({ html }) => 8 | ({ attributes }) => 9 | html`

    hello ${attributes.name}

    `, 10 | ); 11 | 12 | el.setAttribute('name', 'Laurent'); 13 | debug.appendChild(el); 14 | await nextTick(); 15 | eq(el.innerHTML, '

    hello Laurent

    '); 16 | }); 17 | 18 | test('component is updated when rendered is called, passing the relevant data', async ({ 19 | eq, 20 | }) => { 21 | const el = fromView( 22 | ({ html }) => 23 | ({ attributes }) => 24 | html`

    hello ${attributes.name}

    `, 25 | ); 26 | el.setAttribute('name', 'Laurent'); 27 | debug.appendChild(el); 28 | 29 | await nextTick(); 30 | 31 | eq(el.innerHTML, '

    hello Laurent

    '); 32 | 33 | el.render({ 34 | attributes: { name: 'Robert' }, 35 | }); 36 | 37 | await nextTick(); 38 | 39 | eq(el.innerHTML, '

    hello Robert

    '); 40 | }); 41 | 42 | test('a text node can have multiple active sites', async ({ eq }) => { 43 | const el = fromView( 44 | ({ html }) => 45 | ({ attributes }) => 46 | html`

    hello ${attributes.firstname} ${attributes.lastname}

    `, 47 | ); 48 | el.setAttribute('firstname', 'Laurent'); 49 | el.setAttribute('lastname', 'Renard'); 50 | debug.appendChild(el); 51 | 52 | await nextTick(); 53 | 54 | eq(el.innerHTML, '

    hello Laurent Renard

    '); 55 | 56 | el.render({ 57 | attributes: { firstname: 'Robert', lastname: 'Marley' }, 58 | }); 59 | 60 | await nextTick(); 61 | 62 | eq(el.innerHTML, '

    hello Robert Marley

    '); 63 | }); 64 | 65 | test('renders a document fragment', async ({ eq }) => { 66 | const el = fromView( 67 | ({ html }) => 68 | ({ attributes }) => 69 | // prettier-ignore 70 | html`

    some title

    hello ${attributes.name}

    `, 71 | ); 72 | 73 | el.setAttribute('name', 'Laurent'); 74 | debug.appendChild(el); 75 | 76 | await nextTick(); 77 | 78 | eq(el.innerHTML, `

    some title

    hello Laurent

    `); 79 | }); 80 | 81 | test('renders a nested template', async ({ eq }) => { 82 | const el = fromView( 83 | ({ html }) => 84 | ({ attributes }) => 85 | // prettier-ignore 86 | html`

    some title

    hello ${attributes.name}

    ${html`

    you are ${attributes.mood}

    `}`, 87 | ); 88 | 89 | el.setAttribute('name', 'Laurent'); 90 | el.setAttribute('mood', 'happy'); 91 | debug.appendChild(el); 92 | 93 | await nextTick(); 94 | 95 | eq( 96 | el.innerHTML, 97 | `

    some title

    hello Laurent

    you are happy

    `, 98 | ); 99 | 100 | el.render({ 101 | attributes: { 102 | name: 'Robert', 103 | mood: 'very happy', 104 | }, 105 | }); 106 | 107 | await nextTick(); 108 | 109 | eq( 110 | el.innerHTML, 111 | `

    some title

    hello Robert

    you are very happy

    `, 112 | ); 113 | }); 114 | 115 | test('A template is static if it is not a function of any state', async ({ 116 | eq, 117 | }) => { 118 | const el = fromView( 119 | ({ html }) => 120 | // prettier-ignore 121 | html`

    some title

    hello there, how are you?

    `, 122 | ); 123 | 124 | debug.appendChild(el); 125 | 126 | await nextTick(); 127 | 128 | eq(el.innerHTML, `

    some title

    hello there, how are you?

    `); 129 | 130 | el.render(); 131 | 132 | await nextTick(); 133 | 134 | eq(el.innerHTML, `

    some title

    hello there, how are you?

    `); 135 | }); 136 | -------------------------------------------------------------------------------- /apps/restaurant-cashier/products/products.css: -------------------------------------------------------------------------------- 1 | #list-section { 2 | display: grid; 3 | grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); 4 | gap: var(--spacing-small); 5 | 6 | > :first-child { 7 | display: grid; 8 | place-content: center; 9 | min-height: 5em; 10 | } 11 | 12 | &:has(:nth-child(2)) > :first-child { 13 | border-width: 2px; 14 | border-color: var(--shadow-color); 15 | border-style: dashed; 16 | border-radius: 4px; 17 | } 18 | } 19 | 20 | app-product-list-item { 21 | --_spacing: calc(var(--spacing-small) * 2 / 3); 22 | display: flex; 23 | flex-direction: column; 24 | padding: var(--_spacing); 25 | } 26 | 27 | .product-card { 28 | font-size: 0.85em; 29 | display: flex; 30 | flex-direction: column; 31 | flex-grow: 1; 32 | gap: var(--_spacing); 33 | 34 | button { 35 | --padding: 4px; 36 | font-size: 0.95em; 37 | } 38 | 39 | > header { 40 | display: flex; 41 | align-items: center; 42 | gap: var(--spacing-small); 43 | justify-content: space-between; 44 | font-size: 0.9em; 45 | } 46 | 47 | h2 { 48 | margin: 0; 49 | } 50 | 51 | p { 52 | font-size: 0.85em; 53 | margin: 0; 54 | flex-grow: 1; 55 | } 56 | } 57 | 58 | .product-card__image-container { 59 | aspect-ratio: 3 / 2; 60 | display: grid; 61 | place-items: center; 62 | background: var(--app-bg); 63 | 64 | &:has(>img) { 65 | background: unset; 66 | } 67 | } 68 | 69 | .product-card__description:empty:after { 70 | content: 'no content'; 71 | display: grid; 72 | place-items: center; 73 | height: 3em; 74 | font-style: italic; 75 | font-size: 0.7em; 76 | } 77 | 78 | div:has(>.product-card__price) { 79 | display: flex; 80 | justify-content: space-between; 81 | align-items: baseline; 82 | } 83 | 84 | .product-card__price > span:first-child { 85 | font-size: 3em; 86 | } 87 | 88 | .product-card__sku { 89 | font-size: 0.9em; 90 | text-transform: uppercase; 91 | display: flex; 92 | align-items: center; 93 | gap: var(--_spacing); 94 | } 95 | 96 | /** 97 | * Product form 98 | */ 99 | 100 | div:has(> .product-form) { 101 | --spacing: var(--spacing-small); 102 | --_large-spacing: calc(var(--spacing) * 4); 103 | } 104 | 105 | .product-form { 106 | display: grid; 107 | grid-template-columns: 0.6fr 0.4fr; 108 | gap: 0 var(--_large-spacing); 109 | 110 | > .action-bar { 111 | padding-block-start: var(--_spacing); 112 | grid-column: 1 / span 2; 113 | } 114 | 115 | label { 116 | grid-column: 1; 117 | 118 | &:has(app-image-uploader){ 119 | grid-column: 2; 120 | grid-row: 1 / span 4; 121 | } 122 | } 123 | 124 | app-image-uploader { 125 | flex-grow: 1; 126 | border: 2px dashed var(--shadow-color); 127 | background-color: var(--surface-bg); 128 | 129 | &::before { 130 | background: linear-gradient(to bottom, rgb(61, 61, 61, 0.3), transparent, rgb(61, 61, 61, 0.3)); 131 | } 132 | 133 | &.dragging { 134 | border-color: var(--action-color); 135 | 136 | border-width: 4px; 137 | } 138 | } 139 | 140 | span:has(+ :where(textarea, input, app-image-uploader):not([required]))::after { 141 | content: '(optional)'; 142 | font-size: 0.8em; 143 | font-style: italic; 144 | } 145 | } 146 | 147 | 148 | @media (max-width: 720px) { 149 | 150 | .product-form { 151 | display: flex; 152 | flex-direction: column; 153 | } 154 | } 155 | 156 | 157 | -------------------------------------------------------------------------------- /apps/restaurant-cashier/cart/cart.css: -------------------------------------------------------------------------------- 1 | #cart-container { 2 | display: flex; 3 | flex-wrap: wrap; 4 | gap: var(--spacing-big); 5 | align-items: flex-start; 6 | } 7 | 8 | app-cart { 9 | --_spacing: var(--spacing-small); 10 | 11 | position: sticky; 12 | top: 1em; 13 | z-index: 99; 14 | 15 | padding: var(--_spacing); 16 | min-width: min(25em, 100%); 17 | min-height: 12em; 18 | flex-grow: 1; 19 | 20 | display: flex; 21 | flex-direction: column; 22 | gap: var(--_spacing); 23 | 24 | h2 { 25 | margin: 0; 26 | } 27 | 28 | .quantity { 29 | display: flex; 30 | align-items: flex-start; 31 | gap: 0.5em; 32 | } 33 | 34 | small { 35 | display: block; 36 | } 37 | 38 | ul { 39 | list-style: none; 40 | font-size: 0.85em; 41 | flex-grow: 1; 42 | padding: 0; 43 | display: grid; 44 | gap: 0.2em 1em; 45 | grid-template-columns: 1fr 5em minmax(4em, min-content); 46 | align-content: start; 47 | } 48 | 49 | li { 50 | display: grid; 51 | grid-column: 1 / -1; 52 | grid-template-columns: subgrid; 53 | align-items: center; 54 | border-bottom: 1px solid var(--form-border-color); 55 | 56 | > :last-child { 57 | margin-left: auto; 58 | } 59 | 60 | } 61 | 62 | p { 63 | text-align: right; 64 | font-size: 0.9em; 65 | } 66 | } 67 | 68 | app-cart-product-list { 69 | flex-grow: 3; 70 | 71 | #available-products-listbox { 72 | --_min-item-size:200px; 73 | list-style: none; 74 | padding: 0; 75 | display: grid; 76 | grid-template-columns: repeat(auto-fit, minmax(var(--_min-item-size), 1fr)); 77 | gap: var(--spacing-big); 78 | 79 | [role=option] { 80 | display: flex; 81 | flex-direction: column; 82 | cursor: pointer; 83 | outline: none; 84 | 85 | &[aria-selected=true] .adorner { 86 | --_mark-scale: 1; 87 | } 88 | 89 | &:where(:hover, :focus-visible) .adorner { 90 | --_accent-color: var(--shadow-color); 91 | --_mark-offset: 4px; 92 | } 93 | } 94 | } 95 | } 96 | 97 | 98 | .adorner { 99 | --_color: currentColor; 100 | --_accent-color: transparent; 101 | --_mark-scale: 0; 102 | --_mark-size: 2.2em; 103 | --_mark-offset: 0; 104 | 105 | position: relative; 106 | isolation: isolate; 107 | display: grid; 108 | place-items: center; 109 | 110 | 111 | &::after { 112 | content: ''; 113 | z-index: -1; 114 | position: absolute; 115 | inset: 0; 116 | margin: auto; 117 | width: var(--_mark-size); 118 | height: var(--_mark-size); 119 | border-radius: 50%; 120 | background-color: var(--_accent-color); 121 | border: 2px solid var(--_color); 122 | transition: all var(--animation-duration); 123 | box-shadow: 0 0 3px 0 black; 124 | outline: 1px solid var(--_accent-color); 125 | outline-offset: var(--_mark-offset); 126 | } 127 | 128 | > ui-icon { 129 | --size: 1.6em; 130 | transform: scale(var(--_mark-scale), var(--_mark-scale)); 131 | transition: transform var(--animation-duration); 132 | } 133 | } 134 | 135 | app-cart-product-item { 136 | font-size: 0.8em; 137 | display: grid; 138 | grid-template-rows: auto minmax(3em, 1fr) auto; 139 | background-size: cover; 140 | background-repeat: no-repeat; 141 | background-position: center; 142 | 143 | > * { 144 | padding: var(--spacing-small); 145 | background-color: rgba(10, 40, 70, 0.65); 146 | color: white; 147 | } 148 | 149 | :last-child { 150 | display: flex; 151 | align-items: center; 152 | justify-content: space-between; 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /apps/restaurant-cashier/products/edit/edit-product.page.js: -------------------------------------------------------------------------------- 1 | import { createElement } from '../../utils/dom.js'; 2 | import { productListService } from '../product-list.service.js'; 3 | import { fromForm } from '../product-list.model.js'; 4 | import { withView } from '@cofn/view'; 5 | import { ImageUploader } from '../image-uploader/image-uploader.component.js'; 6 | import { compose } from '../../utils/functions.js'; 7 | import { withProps } from '@cofn/controllers'; 8 | export const loadPage = async ({ define, state }) => { 9 | define('app-edit-product', EditProductForm); 10 | define('app-image-uploader', ImageUploader, { 11 | observedAttributes: ['url', 'status'], 12 | shadow: { 13 | mode: 'open', 14 | delegatesFocus: true, 15 | }, 16 | }); 17 | 18 | const { ['product-sku']: sku } = state.navigation.params; 19 | // todo redirect to not found page if product does not exist 20 | // todo bis maybe set this check/redirect in a router middleware 21 | const product = await productListService.fetchOne({ 22 | sku, 23 | }); 24 | const el = createElement('app-edit-product'); 25 | el.product = product; 26 | return { 27 | title: `Edit ${product.title}`, 28 | content: el, 29 | }; 30 | }; 31 | 32 | const wrapComponent = compose([withProps(['product']), withView]); 33 | 34 | const EditProductForm = wrapComponent(({ html, router, $host }) => { 35 | return ({ properties: { product } }) => html` 36 |

    Edit product #${product.sku.toUpperCase()}

    37 |
    38 |
    39 | 42 | 48 | 54 | 60 | 66 | 69 |
    70 | 71 | cancel 72 | 76 |
    77 |
    78 |
    79 | `; 80 | function handleSubmit(ev) { 81 | ev.preventDefault(); 82 | ev.stopPropagation(); 83 | const { target: form } = ev; 84 | form.disabled = true; 85 | if (!form.checkValidity()) { 86 | return; 87 | } 88 | const product = fromForm(form); 89 | productListService.update({ product }); 90 | form.parentElement.classList.toggle('transition-card-expand'); 91 | form.parentElement.classList.toggle('transition-card-collapse'); 92 | router.goTo('products', product); 93 | } 94 | 95 | function handleImageUploaded(ev) { 96 | const { url } = ev.detail; 97 | $host.querySelector('[name=image]').value = url; 98 | } 99 | }); 100 | -------------------------------------------------------------------------------- /apps/restaurant-cashier/components/ui-listbox.component.js: -------------------------------------------------------------------------------- 1 | import { compose, noop } from '../utils/functions.js'; 2 | import { createTreeWalker } from '../utils/dom.js'; 3 | import { bind } from '../utils/objects.js'; 4 | 5 | export const UIListbox = function* ({ $host, $root }) { 6 | $host.setAttribute('tabindex', '0'); 7 | $host.setAttribute('role', 'listbox'); 8 | 9 | Object.defineProperties($host, { 10 | value: { 11 | enumerable: true, 12 | get() { 13 | return $host.selectedOptions.map(({ value }) => value); 14 | }, 15 | }, 16 | selectedOptions: { 17 | enumerable: true, 18 | get() { 19 | return Array.from( 20 | $host.querySelectorAll('[role=option][aria-selected=true]'), 21 | ); 22 | }, 23 | }, 24 | }); 25 | 26 | const treeWalker = createTreeWalker( 27 | $root, 28 | NodeFilter.SHOW_ELEMENT, 29 | traverseOptions, 30 | ); 31 | 32 | const { nextNode, previousNode } = bind(treeWalker); 33 | const toggleOptionSection = compose([toggleSelection, findTarget]); 34 | const movePrevious = compose([makeFocus, previousNode]); 35 | const moveNext = compose([makeFocus, nextNode]); 36 | 37 | const keyMapHandler = { 38 | ['Enter']: toggleOptionSection, 39 | [' ']: compose([ 40 | toggleOptionSection, 41 | (ev) => { 42 | ev.preventDefault(); // avoid scroll on space 43 | return ev; 44 | }, 45 | ]), 46 | ['ArrowUp']: movePrevious, 47 | ['ArrowLeft']: movePrevious, 48 | ['ArrowDown']: moveNext, 49 | ['ArrowRight']: moveNext, 50 | }; 51 | 52 | $host.addEventListener('focusin', compose([handleFocusin, findTarget])); 53 | $host.addEventListener('click', toggleOptionSection); 54 | $host.addEventListener('keydown', handleKeydown); 55 | 56 | function toggleSelection(option) { 57 | if (option.selected !== undefined) { 58 | option.selected = !option.selected; 59 | makeFocus(option); 60 | $host.dispatchEvent( 61 | new CustomEvent('selection-changed', { 62 | detail: { 63 | option, 64 | }, 65 | }), 66 | ); 67 | } 68 | Array.from($root.querySelectorAll('[role=option]')).forEach((option) => { 69 | option.setAttribute('tabindex', '-1'); 70 | }); 71 | $host.setAttribute( 72 | 'tabindex', 73 | $host.selectedOptions.length > 0 ? '-1' : '0', 74 | ); 75 | $host.selectedOptions[0]?.setAttribute('tabindex', '0'); 76 | } 77 | 78 | function findTarget(ev) { 79 | return ( 80 | ev.composedPath().find((el) => el.getAttribute?.('role') === 'option') ?? 81 | ev.target 82 | ); 83 | } 84 | 85 | function handleKeydown(ev) { 86 | const handler = keyMapHandler[ev.key] ?? noop; 87 | handler(ev); 88 | } 89 | 90 | function makeFocus(el) { 91 | el?.focus(); 92 | } 93 | 94 | function handleFocusin(option) { 95 | $host.setAttribute('aria-activedescendant', option?.id ?? ''); 96 | treeWalker.currentNode = option; 97 | } 98 | }; 99 | 100 | export const UIListboxOption = function* ({ $host }) { 101 | let _value = $host.getAttribute('value') || $host.value; 102 | $host.setAttribute('role', 'option'); 103 | $host.setAttribute('tabindex', '-1'); 104 | $host.setAttribute( 105 | 'aria-selected', 106 | $host.selected !== undefined //upgrade property if needed 107 | ? $host.selected 108 | : $host.getAttribute('aria-selected') ?? 'false', 109 | ); 110 | 111 | Object.defineProperties($host, { 112 | selected: { 113 | enumerable: true, 114 | get() { 115 | return $host.getAttribute('aria-selected') === 'true'; 116 | }, 117 | set(value) { 118 | $host.setAttribute('aria-selected', value); 119 | }, 120 | }, 121 | value: { 122 | enumerable: true, 123 | get() { 124 | return _value !== undefined ? _value : $host.textContent; 125 | }, 126 | set(value) { 127 | _value = value; 128 | }, 129 | }, 130 | }); 131 | }; 132 | 133 | const traverseOptions = (node) => 134 | node.getAttribute?.('role') === 'option' 135 | ? NodeFilter.FILTER_ACCEPT 136 | : NodeFilter.FILTER_SKIP; 137 | -------------------------------------------------------------------------------- /apps/restaurant-cashier/products/image-uploader/image-uploader.component.js: -------------------------------------------------------------------------------- 1 | import { createElement } from '../../utils/dom.js'; 2 | import { imagesService } from './images.service.js'; 3 | 4 | const template = createElement('template'); 5 | template.innerHTML = ` 6 | 67 | 68 | 69 | 70 | 71 | `; 72 | 73 | export const ImageUploader = function* ({ $host, $root, $signal: signal }) { 74 | $root.append(template.content.cloneNode(true)); 75 | const img = $root.querySelector('img'); 76 | const input = $root.querySelector('input'); 77 | const button = $root.querySelector('button'); 78 | 79 | window.addEventListener('dragenter', handleDragEnter, { signal }); 80 | window.addEventListener('drop', windowDrop, { signal }); 81 | window.addEventListener('dragover', handleDragOver, { signal }); 82 | $host.addEventListener('click', () => input.click()); 83 | $host.addEventListener('drop', handleDrop); 84 | input.addEventListener('change', () => { 85 | const { files } = input; 86 | const file = files.item(0); 87 | handleFileChange(file); 88 | }); 89 | 90 | while (true) { 91 | const { attributes } = yield; 92 | const { url = '', status = 'idle' } = attributes; 93 | const label = getLabel({ url, status }); 94 | input.disabled = button.disabled = status === 'loading'; 95 | button.textContent = label; 96 | img.setAttribute('src', url); 97 | } 98 | 99 | async function handleFileChange(file) { 100 | $host.setAttribute('status', 'loading'); 101 | try { 102 | const { url } = await imagesService.uploadImage({ file }); 103 | $host.setAttribute('url', url); 104 | $host.setAttribute('status', 'idle'); 105 | $host.dispatchEvent( 106 | new CustomEvent('image-uploaded', { 107 | detail: { 108 | url, 109 | }, 110 | }), 111 | ); 112 | } catch (e) { 113 | console.error(e); 114 | $host.setAttribute('status', 'error'); 115 | } 116 | } 117 | function handleDrop(ev) { 118 | ev.preventDefault(); 119 | const { items } = ev.dataTransfer; 120 | if (items && items[0]?.kind === 'file') { 121 | const file = items[0].getAsFile(); 122 | handleFileChange(file); 123 | } 124 | } 125 | 126 | function windowDrop(ev) { 127 | ev.preventDefault(); 128 | ev.stopPropagation(); 129 | $host.classList.toggle('dragging', false); 130 | } 131 | 132 | function handleDragEnter(ev) { 133 | ev.preventDefault(); 134 | ev.stopPropagation(); 135 | $host.classList.toggle('dragging', true); 136 | } 137 | 138 | function handleDragOver(ev) { 139 | ev.preventDefault(); 140 | ev.stopPropagation(); 141 | } 142 | }; 143 | 144 | const getLabel = ({ url, status }) => { 145 | if (status === 'loading') { 146 | return 'uploading image...'; 147 | } 148 | 149 | if (status === 'error') { 150 | return 'an error occurred, try again'; 151 | } 152 | 153 | return (url ? 'change image' : 'add an image') + '(or drop a file)'; 154 | }; 155 | -------------------------------------------------------------------------------- /apps/todomvc/theme.css: -------------------------------------------------------------------------------- 1 | *, 2 | *::before, 3 | *::after { 4 | box-sizing: border-box; 5 | } 6 | 7 | * { 8 | margin: 0; 9 | } 10 | 11 | body { 12 | line-height: 1.4; 13 | margin: unset; 14 | -webkit-font-smoothing: antialiased; 15 | } 16 | 17 | button, 18 | input, 19 | textarea, 20 | select { 21 | font: inherit; 22 | } 23 | 24 | p, h1, h2, h3, h4, h5, h6 { 25 | overflow-wrap: break-word; 26 | } 27 | 28 | img, 29 | picture, 30 | svg, 31 | canvas { 32 | display: block; 33 | max-inline-size: 100%; 34 | block-size: auto; 35 | } 36 | 37 | 38 | :root { 39 | 40 | --main-hue: 216; 41 | --main-saturation: 100%; 42 | --font-lightness: 22%; 43 | --font-color: hsl(var(--main-hue), var(--main-saturation), var(--font-lightness)); 44 | 45 | --app-bg: #f8f8f8; 46 | --danger-color: #a80000; 47 | --surface-bg: white; 48 | --max-width: 700px; 49 | } 50 | 51 | body { 52 | color: var(--font-color); 53 | font-size: clamp(1.3rem, 0.5vw + 1rem, 1.4rem); 54 | font-family: system-ui, monospace, sans-serif; 55 | background: var(--app-bg); 56 | padding: 1rem; 57 | 58 | > * { 59 | max-width: min(100%, var(--max-width)); 60 | margin-inline: auto; 61 | } 62 | } 63 | 64 | h1 { 65 | font-size: 1.5em; 66 | } 67 | 68 | ui-icon { 69 | --_size: var(--size, 1em); 70 | aspect-ratio: 1 / 1; 71 | display: inline-block; 72 | width: var(--_size); 73 | height: var(--_size); 74 | } 75 | 76 | :is(span, button, a):has(ui-icon:first-child) { 77 | display: flex; 78 | align-items: center; 79 | gap: 8px; 80 | } 81 | 82 | input[type=text], 83 | button, 84 | ::part(button), 85 | #toggle-all { 86 | --_color: var(--color, var(--font-color)); 87 | --_shadow-color: var(--shadow-color, lightgray); 88 | --_bg-color: var(--bg-color, var(--surface-bg)); 89 | --_font-size: var(--font-size, 0.9em); 90 | 91 | white-space: nowrap; 92 | border: 1px solid var(--_color); 93 | color: var(--_color); 94 | border-radius: 4px; 95 | box-shadow: 0 2px 2px 0 var(--_shadow-color); 96 | background: var(--_bg-color); 97 | font-size: var(--_font-size); 98 | padding: 0.3em; 99 | } 100 | 101 | input:focus-visible { 102 | --_bg-color: #ffffd8; 103 | } 104 | 105 | .visually-hidden { 106 | position: absolute; 107 | width: 1px; 108 | height: 1px; 109 | margin: -1px; 110 | border: 0; 111 | padding: 0; 112 | white-space: nowrap; 113 | clip-path: inset(100%); 114 | clip: rect(0 0 0 0); 115 | overflow: hidden; 116 | } 117 | 118 | main > * { 119 | margin-block: 1rem; 120 | display: block; 121 | } 122 | 123 | #add-todo-form { 124 | max-width: min(100%, 520px); 125 | font-size: 1.5rem; 126 | transition: all 0.3s; 127 | 128 | &:focus-within { 129 | max-width: 100%; 130 | } 131 | 132 | } 133 | 134 | #add-todo-controls { 135 | display: flex; 136 | flex-wrap: wrap; 137 | gap: 0.5em; 138 | 139 | > input { 140 | min-width: min(100%, 400px); 141 | flex-grow: 1; 142 | } 143 | 144 | > button { 145 | margin-inline-start: auto; 146 | } 147 | 148 | } 149 | 150 | #todo-list { 151 | --checkbox-width: 2em; 152 | --left-padding: 0.75em; 153 | 154 | > * { 155 | margin-block: 1em; 156 | } 157 | } 158 | 159 | app-todo-list { 160 | --gap: 0.5em; 161 | display: flex; 162 | flex-direction: column; 163 | gap: var(--gap); 164 | background: var(--surface-bg); 165 | padding-inline: var(--left-padding); 166 | border: 1px solid currentColor; 167 | border-radius: 4px; 168 | 169 | &:empty { 170 | border: unset; 171 | } 172 | } 173 | 174 | app-todo { 175 | padding-block: 0.5em; 176 | border-bottom: 1px solid gray; 177 | 178 | &:last-child { 179 | border-bottom: unset; 180 | } 181 | } 182 | 183 | [is=app-controls] { 184 | display: grid; 185 | grid-template-areas: 186 | "filter filter filter" 187 | "toggle-all info clear-all"; 188 | grid-template-columns: 100px 1fr 9em; 189 | gap: 0.5em; 190 | align-items: center; 191 | font-size: 0.8em; 192 | 193 | > fieldset { 194 | grid-area: filter; 195 | margin-inline: auto; 196 | } 197 | 198 | > p { 199 | text-align: center; 200 | grid-area: info; 201 | } 202 | 203 | } 204 | 205 | #toggle-all { 206 | display: grid; 207 | align-items: center; 208 | grid-template-columns: var(--checkbox-width) auto; 209 | margin-left: calc(var(--left-padding) + 0.3em); 210 | } 211 | -------------------------------------------------------------------------------- /apps/restaurant-cashier/app.js: -------------------------------------------------------------------------------- 1 | import { define } from '@cofn/core'; 2 | import { UIIcon } from './components/ui-icon.component.js'; 3 | import { PageLink } from './router/page-link.component.js'; 4 | import { PageOutlet } from './router/page-outlet.component.js'; 5 | import { navigationEvents } from './router/router.js'; 6 | import { createAnimationsService } from './utils/animations.service.js'; 7 | import { createStorageService } from './utils/storage.service.js'; 8 | import { 9 | createPreferencesService, 10 | motionSettings, 11 | preferencesEvents, 12 | themeSettings, 13 | } from './users/preferences.service.js'; 14 | import { querySelector } from './utils/dom.js'; 15 | import { compose } from './utils/functions.js'; 16 | import { mapValues } from './utils/objects.js'; 17 | import { UiLabelComponent } from './components/ui-label.component.js'; 18 | import { notificationsService } from './utils/notifications.service.js'; 19 | import { UIAlert } from './components/ui-alert.component.js'; 20 | 21 | const togglePreferences = ({ motion, theme }) => { 22 | const classList = querySelector('body').classList; 23 | classList.toggle('dark', theme === themeSettings.dark); 24 | classList.toggle('motion-reduced', motion === motionSettings.reduced); 25 | }; 26 | 27 | // todo: we need to repeat the loading page or vite gets lost 28 | 29 | export const createApp = ({ router }) => { 30 | const storageService = createStorageService(); 31 | const preferencesService = createPreferencesService({ 32 | storageService, 33 | }); 34 | const animationService = createAnimationsService({ 35 | preferencesService, 36 | }); 37 | 38 | preferencesService.on( 39 | preferencesEvents.PREFERENCES_CHANGED, 40 | compose([ 41 | togglePreferences, 42 | mapValues(({ computed }) => computed), 43 | preferencesService.getState, 44 | ]), 45 | ); 46 | 47 | const root = { 48 | animationService, 49 | router, 50 | storageService, 51 | preferencesService, 52 | notificationsService, 53 | }; 54 | const withRoot = (comp) => 55 | function* (deps) { 56 | yield* comp({ 57 | ...root, 58 | ...deps, 59 | }); 60 | }; 61 | 62 | const _define = (tag, comp, ...rest) => { 63 | if (!customElements.get(tag)) { 64 | define(tag, withRoot(comp), ...rest); 65 | } 66 | }; 67 | 68 | _define('ui-icon', UIIcon, { 69 | shadow: { mode: 'open' }, 70 | observedAttributes: ['name'], 71 | }); 72 | _define('ui-label', UiLabelComponent, { 73 | extends: 'label', 74 | }); 75 | _define('ui-page-link', PageLink, { 76 | extends: 'a', 77 | }); 78 | _define('ui-page-outlet', PageOutlet); 79 | _define('ui-alert', UIAlert, { 80 | shadow: { 81 | mode: 'open', 82 | }, 83 | }); 84 | async function loadPage(ctx, next) { 85 | const page = await ctx.module.loadPage({ 86 | state: ctx.state, 87 | ...root, 88 | define: _define, 89 | }); 90 | router.emit({ 91 | type: navigationEvents.PAGE_LOADED, 92 | detail: { page }, 93 | }); 94 | return next(); 95 | } 96 | 97 | router 98 | .addRoute({ pattern: 'me' }, [ 99 | async (ctx, next) => { 100 | ctx.module = await import('/users/me.page.js'); 101 | next(); 102 | }, 103 | loadPage, 104 | ]) 105 | .addRoute({ pattern: 'dashboard' }, [ 106 | async (ctx, next) => { 107 | ctx.module = await import('/dashboard/dashboard.page.js'); 108 | next(); 109 | }, 110 | loadPage, 111 | ]) 112 | .addRoute({ pattern: 'products' }, [ 113 | async (ctx, next) => { 114 | ctx.module = await import('/products/list/product-list.page.js'); 115 | next(); 116 | }, 117 | loadPage, 118 | ]) 119 | .addRoute({ pattern: 'products/new' }, [ 120 | async (ctx, next) => { 121 | ctx.module = await import('/products/new/new-product.page.js'); 122 | next(); 123 | }, 124 | loadPage, 125 | ]) 126 | .addRoute({ pattern: 'products/:product-sku' }, [ 127 | async (ctx, next) => { 128 | ctx.module = await import('/products/edit/edit-product.page.js'); 129 | next(); 130 | }, 131 | loadPage, 132 | ]) 133 | .addRoute({ pattern: 'cart' }, [ 134 | async (ctx, next) => { 135 | ctx.module = await import('/cart/cart.page.js'); 136 | next(); 137 | }, 138 | loadPage, 139 | ]) 140 | .notFound(() => { 141 | router.redirect('/dashboard'); 142 | }); 143 | 144 | return { 145 | start() { 146 | // trigger initial preference state 147 | preferencesService.emit({ 148 | type: preferencesEvents.PREFERENCES_CHANGED, 149 | }); 150 | router.redirect(location.pathname + location.search + location.hash); 151 | }, 152 | }; 153 | }; 154 | 155 | const useLogger = (ctx, next) => { 156 | console.debug(`loading route: ${ctx.state.navigation?.URL}`); 157 | return next(); 158 | }; 159 | --------------------------------------------------------------------------------