From 570c2b1572fd2133d7192026e80f73c7d4d635f5 Mon Sep 17 00:00:00 2001 From: Keith Cirkel Date: Mon, 5 Sep 2022 15:38:46 +0100 Subject: [PATCH 1/3] upgrade typescript to latest --- package-lock.json | 14 +++++++------- package.json | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0ec7de9e..006b3ba7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,7 @@ "sinon": "^13.0.1", "size-limit": "^7.0.8", "tslib": "^2.4.0", - "typescript": "^4.6.3" + "typescript": "^4.8.2" } }, "node_modules/@babel/code-frame": { @@ -8052,9 +8052,9 @@ } }, "node_modules/typescript": { - "version": "4.6.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.6.3.tgz", - "integrity": "sha512-yNIatDa5iaofVozS/uQJEl3JRWLKKGJKh6Yaiv0GLGSuhpFJe7P3SbHZ8/yjAHRQwKRoA6YZqlfjXWmVzoVSMw==", + "version": "4.8.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.8.2.tgz", + "integrity": "sha512-C0I1UsrrDHo2fYI5oaCGbSejwX4ch+9Y5jTQELvovfmFkK3HHSZJB8MSJcWLmCUBzQBchCrZ9rMRV6GuNrvGtw==", "dev": true, "bin": { "tsc": "bin/tsc", @@ -14826,9 +14826,9 @@ } }, "typescript": { - "version": "4.6.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.6.3.tgz", - "integrity": "sha512-yNIatDa5iaofVozS/uQJEl3JRWLKKGJKh6Yaiv0GLGSuhpFJe7P3SbHZ8/yjAHRQwKRoA6YZqlfjXWmVzoVSMw==", + "version": "4.8.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.8.2.tgz", + "integrity": "sha512-C0I1UsrrDHo2fYI5oaCGbSejwX4ch+9Y5jTQELvovfmFkK3HHSZJB8MSJcWLmCUBzQBchCrZ9rMRV6GuNrvGtw==", "dev": true }, "typical": { diff --git a/package.json b/package.json index b487ad71..fb867e6b 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "sinon": "^13.0.1", "size-limit": "^7.0.8", "tslib": "^2.4.0", - "typescript": "^4.6.3" + "typescript": "^4.8.2" }, "size-limit": [ { From bdcbd42b01a5fd98238389c30e72ff17de5d2a19 Mon Sep 17 00:00:00 2001 From: Keith Cirkel Date: Mon, 5 Sep 2022 15:41:36 +0100 Subject: [PATCH 2/3] add stylable behavior --- docs/_guide/styleable.md | 104 +++++++++++++++++++++++++++++++++++++++ src/stylable.ts | 53 ++++++++++++++++++++ test/styleable.ts | 54 ++++++++++++++++++++ 3 files changed, 211 insertions(+) create mode 100644 docs/_guide/styleable.md create mode 100644 src/stylable.ts create mode 100644 test/styleable.ts diff --git a/docs/_guide/styleable.md b/docs/_guide/styleable.md new file mode 100644 index 00000000..9e203600 --- /dev/null +++ b/docs/_guide/styleable.md @@ -0,0 +1,104 @@ +--- +chapter: 8 +subtitle: Bringing CSS into ShadowDOM +hidden: true +--- + +Components with ShadowDOM typically want to introduce some CSS into their ShadowRoots. This is done with the use of `adoptedStyleSheets`, which can be a little cumbersome, so Catalyst provides the `@style` decorator and `css` utility function to more easily add CSS to your component. + +If your CSS lives in a different file, you can import the file with using the `assert { type: 'css' }` import assertion. You might need to configure your bundler tool to allow for this. If you're unfamiliar with this feature, you can [check out the web.dev article on CSS Module Scripts](https://web.dev/css-module-scripts/): + +```typescript +import {controller, style} from '@github/catalyst' +import DesignSystemCSS from './my-design-system.css' assert { type: 'css' } + +@controller +class UserRow extends HTMLElement { + @style designSystem = DesignSystemCSS + + connectedCallback() { + this.attachShadow({ mode: 'open' }) + // adoptedStyleSheets now includes our DesignSystemCSS! + console.assert(this.shadowRoot.adoptedStyleSheets.includes(this.designSystem)) + } +} +``` + +Multiple `@style` tags are allowed, each one will be applied to the `adoptedStyleSheets` meaning you can split your CSS without worry! + +```typescript +import {controller} from '@github/catalyst' +import UtilityCSS from './my-design-system/utilities.css' assert { type: 'css' } +import NormalizeCSS from './my-design-system/normalize.css' assert { type: 'css' } +import UserRowCSS from './my-design-system/components/user-row.css' assert { type: 'css' } + +@controller +class UserRow extends HTMLElement { + @style utilityCSS = UtilityCSS + @style normalizeCSS = NormalizeCSS + @style userRowCSS = UserRowCSS + + connectedCallback() { + this.attachShadow({ mode: 'open' }) + } +} +``` + +### Defining CSS in JS + +Sometimes it can be useful to define small snippets of CSS within JavaScript itself, and so for this we have the `css` helper function which can create a `CSSStyleSheet` object on-the-fly: + +```typescript +import {controller, style, css} from '@github/catalyst' + +@controller +class UserRow extends HTMLElement { + @style componentCSS = css`:host { display: flex }` + + connectedCallback() { + this.attachShadow({ mode: 'open' }) + } +} +``` + +As always though, the best way to handle dynamic per-instance values is with CSS variables: + +```typescript +import {controller, style, css} from '@github/catalyst' + +const sizeCSS = (size = 1) => css`:host { font-size: var(--font-size, ${size}em); }` + +@controller +class UserRow extends HTMLElement { + @style componentCSS = sizeCSS + + @attr set fontSize(n: number) { + this.style.setProperty('--font-size', n) + } +} +``` +```html +Alex +Riley +``` + +The `css` function is memoized; it will always return the same `CSSStyleSheet` object for every callsite. This allows you to "lift" it into a function that can change the CSS for all components by calling the function, which will replace the CSS inside it. + +```typescript +import {controller, style, css} from '@github/catalyst' + +const sizeCSS = (size = 1) => css`:host { font-size: ${size}em; }` + +// Calling sizeCSS will always result in the same CSSStyleSheet object +console.assert(sizeCSS(1) === sizeCSS(2)) + +@controller +class UserRow extends HTMLElement { + @style componentCSS = sizeCSS + + #size = 1 + makeAllUsersLargerFont() { + sizeCSS(this.#size++) + } +} +``` diff --git a/src/stylable.ts b/src/stylable.ts new file mode 100644 index 00000000..cd9cabfc --- /dev/null +++ b/src/stylable.ts @@ -0,0 +1,53 @@ +import type {CustomElementClass, CustomElement} from './custom-element.js' +import {controllable, attachShadowCallback} from './controllable.js' +import {createMark} from './mark.js' +import {createAbility} from './ability.js' + +type TemplateString = {raw: readonly string[] | ArrayLike} + +const cssMem = new WeakMap() +export const css = (strings: TemplateString, ...values: unknown[]): CSSStyleSheet => { + if (!cssMem.has(strings)) cssMem.set(strings, new CSSStyleSheet()) + const sheet = cssMem.get(strings)! + sheet.replaceSync(String.raw(strings, ...values)) + return sheet +} + +const [style, getStyle, initStyle] = createMark( + ({name, kind}) => { + if (kind === 'setter') throw new Error(`@style cannot decorate setter ${String(name)}`) + if (kind === 'method') throw new Error(`@style cannot decorate method ${String(name)}`) + }, + (instance: CustomElement, {name, kind, access}) => { + return { + get: () => (kind === 'getter' ? access.get!.call(instance) : access.value), + set: () => { + throw new Error(`Cannot set @style ${String(name)}`) + } + } + } +) + +export {style, getStyle} +export const stylable = createAbility( + (Class: T): T => + class extends controllable(Class) { + [key: PropertyKey]: unknown + + // TS mandates Constructors that get mixins have `...args: any[]` + // eslint-disable-next-line @typescript-eslint/no-explicit-any + constructor(...args: any[]) { + super(...args) + initStyle(this) + } + + [attachShadowCallback](root: ShadowRoot) { + super[attachShadowCallback]?.(root) + const styleProps = getStyle(this) + if (!styleProps.size) return + const styles = new Set([...root.adoptedStyleSheets]) + for (const name of styleProps) styles.add(this[name] as CSSStyleSheet) + root.adoptedStyleSheets = [...styles] + } + } +) diff --git a/test/styleable.ts b/test/styleable.ts new file mode 100644 index 00000000..71b5f94d --- /dev/null +++ b/test/styleable.ts @@ -0,0 +1,54 @@ +import {expect, fixture, html} from '@open-wc/testing' +import {style, css, stylable} from '../src/stylable.js' + +describe('Styleable', () => { + const globalCSS = ({color}: {color: string}) => + css` + :host { + color: ${color}; + } + ` + + @stylable + class StylableTest extends HTMLElement { + @style foo = css` + body { + display: block; + } + ` + @style bar = globalCSS({color: 'rgb(255, 105, 180)'}) + + constructor() { + super() + this.attachShadow({mode: 'open'}).innerHTML = '

Hello

' + } + } + window.customElements.define('stylable-test', StylableTest) + + it('adoptes styles into shadowRoot', async () => { + const instance = await fixture(html``) + expect(instance.foo).to.be.instanceof(CSSStyleSheet) + expect(instance.bar).to.be.instanceof(CSSStyleSheet) + expect(instance.shadowRoot!.adoptedStyleSheets).to.eql([instance.foo, instance.bar]) + }) + + it('updates stylesheets that get recomputed', async () => { + const instance = await fixture(html``) + expect(getComputedStyle(instance.shadowRoot!.children[0]!).color).to.equal('rgb(255, 105, 180)') + globalCSS({color: 'rgb(0, 0, 0)'}) + expect(getComputedStyle(instance.shadowRoot!.children[0]!).color).to.equal('rgb(0, 0, 0)') + }) + + it('throws an error when trying to set stylesheet', async () => { + const instance = await fixture(html``) + expect(() => (instance.foo = css``)).to.throw(/Cannot set @style/) + }) + + describe('css', () => { + it('returns the same CSSStyleSheet for subsequent calls from same template string', () => { + expect(css``).to.not.equal(css``) + const mySheet = () => css`` + expect(mySheet()).to.equal(mySheet()) + }) + }) +}) From 41de4c57600f50ff7b4ed3f441190d3f952e6531 Mon Sep 17 00:00:00 2001 From: Keith Cirkel Date: Mon, 5 Sep 2022 16:36:40 +0100 Subject: [PATCH 3/3] make marks observable --- src/controllable.ts | 13 ++++++++++ src/mark.ts | 37 +++++++++++++++++++++++---- test/controllable.ts | 60 +++++++++++++++++++++++++++++++++++++++++++- test/mark.ts | 36 +++++++++++++++++++++++++- 4 files changed, 139 insertions(+), 7 deletions(-) diff --git a/src/controllable.ts b/src/controllable.ts index a92368a0..4b25900d 100644 --- a/src/controllable.ts +++ b/src/controllable.ts @@ -1,9 +1,11 @@ import type {CustomElementClass, CustomElement} from './custom-element.js' import {createAbility} from './ability.js' +import {observe} from './mark.js' export interface Controllable { [attachShadowCallback]?(shadowRoot: ShadowRoot): void [attachInternalsCallback]?(internals: ElementInternals): void + [markChangedCallback]?(props: Map): void } export interface ControllableClass { // TS mandates Constructors that get mixins have `...args: any[]` @@ -13,6 +15,7 @@ export interface ControllableClass { export const attachShadowCallback = Symbol() export const attachInternalsCallback = Symbol() +export const markChangedCallback = Symbol() const shadows = new WeakMap() const internals = new WeakMap() @@ -33,6 +36,16 @@ export const controllable = createAbility( // Ignore errors } } + const queue = new Map() + observe(this, async (prop: PropertyKey, oldValue: unknown, newValue: unknown) => { + if (Object.is(newValue, oldValue)) return + queue.set(prop, oldValue) + if (queue.size > 1) return + await Promise.resolve() + const changed = new Map(queue) + queue.clear() + ;(this as Controllable)[markChangedCallback]?.(changed) + }) } connectedCallback() { diff --git a/src/mark.ts b/src/mark.ts index 31e263a0..02db86af 100644 --- a/src/mark.ts +++ b/src/mark.ts @@ -23,9 +23,16 @@ const getType = (descriptor?: PropertyDescriptor): PropertyType => { return 'field' } +type observer = (key: PropertyKey, oldValue: unknown, newValue: unknown) => void +const observers = new WeakMap>() +export function observe(instance: T, observer: observer) { + if (!observers.has(instance)) observers.set(instance, new Set()) + observers.get(instance)!.add(observer) +} + export function createMark( - validate: (context: {name: PropertyKey; kind: PropertyType}) => void, - initialize: (instance: T, context: Context) => PropertyDescriptor | void + validate?: (context: {name: PropertyKey; kind: PropertyType}) => void, + initialize?: (instance: T, context: Context) => PropertyDescriptor | void ): [PropertyDecorator, GetMarks, InitializeMarks] { const marks = new WeakMap>() const get = (proto: object): Set => { @@ -37,7 +44,7 @@ export function createMark( } const marker = (proto: object, name: PropertyKey, descriptor?: PropertyDescriptor): void => { if (get(proto).has(name)) return - validate({name, kind: getType(descriptor)}) + validate?.({name, kind: getType(descriptor)}) get(proto).add(name) } marker.static = Symbol() @@ -53,14 +60,34 @@ export function createMark( getMarks, (instance: T): void => { for (const name of getMarks(instance)) { + let value = (instance as Record)[name] const access: PropertyDescriptor = getPropertyDescriptor(instance, name) || { value: void 0, configurable: true, writable: true, enumerable: true } - const newDescriptor = initialize(instance, {name, kind: getType(access), access}) || access - Object.defineProperty(instance, name, Object.assign({configurable: true, enumerable: true}, newDescriptor)) + const kind = getType(access) + const { + writable, + configurable = true, + enumerable = true, + set, + get: getter = () => value, + value: initValue + } = initialize?.(instance, {name, kind, access}) || access + if (typeof initValue !== 'undefined') value = initValue + Object.defineProperty(instance, name, { + configurable, + enumerable, + get: getter, + set(newValue: unknown) { + if (!set && !writable) throw new TypeError(`"${String(name)}" is read-only`) + for (const observer of observers.get(this) || []) observer.call(this, name, value, newValue) + set?.call(this, newValue) + value = (this as Record)[name] + } + }) } } ] diff --git a/test/controllable.ts b/test/controllable.ts index 812819d9..2dae5a8a 100644 --- a/test/controllable.ts +++ b/test/controllable.ts @@ -1,7 +1,8 @@ import type {CustomElementClass, CustomElement} from '../src/custom-element.js' import {expect, fixture, html} from '@open-wc/testing' import {fake} from 'sinon' -import {controllable, attachShadowCallback, attachInternalsCallback} from '../src/controllable.js' +import {controllable, attachShadowCallback, attachInternalsCallback, markChangedCallback} from '../src/controllable.js' +import {createMark} from '../src/mark.js' describe('controllable', () => { describe('attachShadowCallback', () => { @@ -180,4 +181,61 @@ describe('controllable', () => { expect(attachInternalsFake).to.have.callCount(0) }) }) + + describe('markChangedCallback', () => { + let markChangedFake: (s: Map) => void + beforeEach(() => { + markChangedFake = fake() + }) + + const [prop, getProp, initProp] = createMark() + const markChangeAbility = (Class: T): T => + class extends controllable(Class) { + constructor() { + super() + initProp(this) + } + [markChangedCallback](...args: [Map]) { + return markChangedFake.apply(this, args) + } + } + + @markChangeAbility + @controllable + class MarkChangedAbility extends HTMLElement { + @prop foo = 1 + @prop bar = 'hi'; + [markChangedCallback]() { + throw new Error('Custom Element concrete class [markChangedCallback] should not have been called') + } + } + customElements.define('mark-changed-ability', MarkChangedAbility) + + it('calls markChangedCallback one Promise tick after a markChange call', async () => { + const instance = await fixture(html``) + instance.foo = 2 + expect(markChangedFake).to.have.callCount(0) + instance.bar = 'bye' + expect(markChangedFake).to.have.callCount(0) + await Promise.resolve() + expect(markChangedFake) + .to.have.callCount(1) + .and.be.calledWith( + new Map([ + ['foo', 1], + ['bar', 'hi'] + ]) + ) + }) + + it('does not call markChangedCallback for equal values', async () => { + const instance = await fixture(html``) + instance.foo = 1 + expect(markChangedFake).to.have.callCount(0) + instance.bar = 'hi' + expect(markChangedFake).to.have.callCount(0) + await Promise.resolve() + expect(markChangedFake).to.have.callCount(0) + }) + }) }) diff --git a/test/mark.ts b/test/mark.ts index 8ad92380..11911bea 100644 --- a/test/mark.ts +++ b/test/mark.ts @@ -1,6 +1,6 @@ import {expect} from '@open-wc/testing' import {fake} from 'sinon' -import {createMark} from '../src/mark.js' +import {createMark, observe} from '../src/mark.js' describe('createMark', () => { it('returns a tuple of functions: mark, getMarks, initializeMarks', () => { @@ -253,4 +253,38 @@ describe('createMark', () => { expect(Array.from(getMarks1(fooBar))).to.eql(['foo', 'bar']) expect(Array.from(getMarks2(fooBar))).to.eql(['foo', 'bar']) }) + + it('can observe changes to marks', () => { + const [mark1, getMarks1, initializeMarks1] = createMark( + fake(), + fake(() => ({get: fake(), set: fake()})) + ) + const [mark2, getMarks2, initializeMarks2] = createMark( + fake(), + fake(() => ({get: fake(), set: fake()})) + ) + const observer = fake() + class FooBar { + @mark1 foo: unknown + @mark2 bar = 'hi' + get baz() { + return 1 + } + @mark1 set baz(_: unknown) {} + + constructor() { + observe(this, observer) + initializeMarks1(this) + initializeMarks2(this) + } + } + const fooBar = new FooBar() + expect(observer).to.have.callCount(0) + fooBar.foo = 1 + expect(observer).to.have.callCount(1).and.be.calledWithExactly('foo', undefined, 1) + fooBar.bar = 'bye' + expect(observer).to.have.callCount(2).and.be.calledWithExactly('bar', 'hi', 'bye') + fooBar.baz = 3 + expect(observer).to.have.callCount(3).and.be.calledWithExactly('baz', 1, 3) + }) })