${product.description ?? ''}
35 |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 |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 |63 | For a total of 64 | ${currentCart.total.amountInCents / 100 + 66 | currentCart.total.currency} 67 | 68 |
` 69 | : html`cart is currently empty
`} 70 | `; 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` 47 | `; 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 |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`hello ${attributes.name}
`, 71 | ); 72 | 73 | el.setAttribute('name', 'Laurent'); 74 | debug.appendChild(el); 75 | 76 | await nextTick(); 77 | 78 | eq(el.innerHTML, `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`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 | `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 | `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`hello there, how are you?
`, 122 | ); 123 | 124 | debug.appendChild(el); 125 | 126 | await nextTick(); 127 | 128 | eq(el.innerHTML, `hello there, how are you?
`); 129 | 130 | el.render(); 131 | 132 | await nextTick(); 133 | 134 | eq(el.innerHTML, `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 |