diff --git a/packages/runtime-vapor/__tests__/dom/patchProp.spec.ts b/packages/runtime-vapor/__tests__/dom/patchProp.spec.ts index 74d7a425d..539364a3c 100644 --- a/packages/runtime-vapor/__tests__/dom/patchProp.spec.ts +++ b/packages/runtime-vapor/__tests__/dom/patchProp.spec.ts @@ -90,13 +90,133 @@ describe('patchProp', () => { setStyle(el, 'color: red') expect(el.style.cssText).toBe('color: red;') }) - test.fails('shoud set style with object and array property', () => { + + test('should work with camelCase', () => { + const el = document.createElement('div') + setStyle(el, { fontSize: '12px' }) + expect(el.style.cssText).toBe('font-size: 12px;') + }) + + test('shoud set style with object and array property', () => { const el = document.createElement('div') setStyle(el, { color: 'red' }) expect(el.style.cssText).toBe('color: red;') setStyle(el, [{ color: 'blue' }, { fontSize: '12px' }]) expect(el.style.cssText).toBe('color: blue; font-size: 12px;') }) + + test('should remove if falsy value', () => { + const el = document.createElement('div') + setStyle(el, { color: undefined, borderRadius: null }) + expect(el.style.cssText).toBe('') + setStyle(el, { color: 'red' }) + expect(el.style.cssText).toBe('color: red;') + setStyle(el, { color: undefined, borderRadius: null }) + expect(el.style.cssText).toBe('') + }) + + test('should work with !important', () => { + const el = document.createElement('div') + setStyle(el, { color: 'red !important' }) + expect(el.style.cssText).toBe('color: red !important;') + }) + + test('should work with camelCase and !important', () => { + const el = document.createElement('div') + setStyle(el, { fontSize: '12px !important' }) + expect(el.style.cssText).toBe('font-size: 12px !important;') + }) + + test('should work with multiple entries', () => { + const el = document.createElement('div') + setStyle(el, { color: 'red', marginRight: '10px' }) + expect(el.style.getPropertyValue('color')).toBe('red') + expect(el.style.getPropertyValue('margin-right')).toBe('10px') + }) + + test('should patch with falsy style value', () => { + const el = document.createElement('div') + setStyle(el, { width: '100px' }) + expect(el.style.cssText).toBe('width: 100px;') + setStyle(el, { width: 0 }) + expect(el.style.cssText).toBe('width: 0px;') + }) + + test('should remove style attribute on falsy value', () => { + const el = document.createElement('div') + setStyle(el, { width: '100px' }) + expect(el.style.cssText).toBe('width: 100px;') + setStyle(el, { width: undefined }) + expect(el.style.cssText).toBe('') + + setStyle(el, { width: '100px' }) + expect(el.style.cssText).toBe('width: 100px;') + setStyle(el, null) + expect(el.hasAttribute('style')).toBe(false) + expect(el.style.cssText).toBe('') + }) + + test('should warn for trailing semicolons', () => { + const el = document.createElement('div') + setStyle(el, { color: 'red;' }) + expect( + `Unexpected semicolon at the end of 'color' style value: 'red;'`, + ).toHaveBeenWarned() + + setStyle(el, { '--custom': '100; ' }) + expect( + `Unexpected semicolon at the end of '--custom' style value: '100; '`, + ).toHaveBeenWarned() + }) + + test('should not warn for trailing semicolons', () => { + const el = document.createElement('div') + setStyle(el, { '--custom': '100\\;' }) + expect(el.style.getPropertyValue('--custom')).toBe('100\\;') + }) + + test('should work with shorthand properties', () => { + const el = document.createElement('div') + setStyle(el, { borderBottom: '1px solid red', border: '1px solid green' }) + expect(el.style.border).toBe('1px solid green') + expect(el.style.borderBottom).toBe('1px solid green') + }) + + // JSDOM doesn't support custom properties on style object so we have to + // mock it here. + function mockElementWithStyle() { + const store: any = {} + return { + style: { + display: '', + WebkitTransition: '', + setProperty(key: string, val: string) { + store[key] = val + }, + getPropertyValue(key: string) { + return store[key] + }, + }, + } + } + + test('should work with css custom properties', () => { + const el = mockElementWithStyle() + setStyle(el as any, { '--theme': 'red' }) + expect(el.style.getPropertyValue('--theme')).toBe('red') + }) + + test('should auto vendor prefixing', () => { + const el = mockElementWithStyle() + setStyle(el as any, { transition: 'all 1s' }) + expect(el.style.WebkitTransition).toBe('all 1s') + }) + + test('should work with multiple values', () => { + const el = mockElementWithStyle() + setStyle(el as any, { display: ['-webkit-box', '-ms-flexbox', 'flex'] }) + expect(el.style.display).toBe('flex') + }) }) describe('setAttr', () => { diff --git a/packages/runtime-vapor/src/dom.ts b/packages/runtime-vapor/src/dom.ts index 6874fa17c..b1c25a8b0 100644 --- a/packages/runtime-vapor/src/dom.ts +++ b/packages/runtime-vapor/src/dom.ts @@ -1,6 +1,7 @@ import { isArray, toDisplayString } from '@vue/shared' import type { Block, ParentBlock } from './render' +export * from './dom/style' export * from './dom/prop' export * from './dom/event' export * from './dom/templateRef' diff --git a/packages/runtime-vapor/src/dom/prop.ts b/packages/runtime-vapor/src/dom/prop.ts index 77c5170b8..638a64cbc 100644 --- a/packages/runtime-vapor/src/dom/prop.ts +++ b/packages/runtime-vapor/src/dom/prop.ts @@ -11,6 +11,7 @@ import { } from '@vue/shared' import { currentInstance } from '../component' import { warn } from '../warning' +import { setStyle } from './style' export function recordPropMetadata(el: Node, key: string, value: any): any { if (!currentInstance) { @@ -34,17 +35,6 @@ export function setClass(el: Element, value: any) { } } -export function setStyle(el: HTMLElement, value: any) { - const prev = recordPropMetadata(el, 'style', (value = normalizeStyle(value))) - if (value !== prev && (value || prev)) { - if (typeof value === 'string') { - el.style.cssText = value - } else { - // TODO - } - } -} - export function setAttr(el: Element, key: string, value: any) { const oldVal = recordPropMetadata(el, key, value) if (value !== oldVal) { diff --git a/packages/runtime-vapor/src/dom/style.ts b/packages/runtime-vapor/src/dom/style.ts new file mode 100644 index 000000000..fbe6bf26e --- /dev/null +++ b/packages/runtime-vapor/src/dom/style.ts @@ -0,0 +1,106 @@ +import { + camelize, + capitalize, + hyphenate, + isArray, + isString, + normalizeStyle, +} from '@vue/shared' +import { warn } from '../warning' +import { recordPropMetadata } from './prop' + +export function setStyle(el: HTMLElement, value: any) { + const prev = recordPropMetadata(el, 'style', (value = normalizeStyle(value))) + patchStyle(el, prev, value) +} + +// TODO copied from packages/runtime-dom/src/modules/style.ts + +type Style = string | Record | null + +function patchStyle(el: Element, prev: Style, next: Style) { + const style = (el as HTMLElement).style + const isCssString = isString(next) + if (next && !isCssString) { + if (prev && !isString(prev)) { + for (const key in prev) { + if (next[key] == null) { + setStyleValue(style, key, '') + } + } + } + + for (const key in next) { + setStyleValue(style, key, next[key]) + } + } else { + if (isCssString) { + // TODO: combine with v-show + if (prev !== next) { + style.cssText = next + } + } else if (prev) { + el.removeAttribute('style') + } + } +} + +const semicolonRE = /[^\\];\s*$/ +const importantRE = /\s*!important$/ + +function setStyleValue( + style: CSSStyleDeclaration, + name: string, + val: string | string[], +) { + if (isArray(val)) { + val.forEach(v => setStyleValue(style, name, v)) + } else { + if (val == null) val = '' + if (__DEV__) { + if (semicolonRE.test(val)) { + warn( + `Unexpected semicolon at the end of '${name}' style value: '${val}'`, + ) + } + } + if (name.startsWith('--')) { + // custom property definition + style.setProperty(name, val) + } else { + const prefixed = autoPrefix(style, name) + if (importantRE.test(val)) { + // !important + style.setProperty( + hyphenate(prefixed), + val.replace(importantRE, ''), + 'important', + ) + } else { + style[prefixed as any] = val + } + } + } +} + +const prefixes = ['Webkit', 'Moz', 'ms'] +const prefixCache: Record = {} + +function autoPrefix(style: CSSStyleDeclaration, rawName: string): string { + const cached = prefixCache[rawName] + if (cached) { + return cached + } + let name = camelize(rawName) + if (name !== 'filter' && name in style) { + return (prefixCache[rawName] = name) + } + name = capitalize(name) + for (let i = 0; i < prefixes.length; i++) { + const prefixed = prefixes[i] + name + if (prefixed in style) { + return (prefixCache[rawName] = prefixed) + } + } + return rawName +}