Tab Completion

I'm Tab Atkins Jr, and I wear many hats. I work for Google on the Chrome browser as a Web Standards Hacker. I'm also a member of the CSS Working Group, and am either a member or contributor to several other working groups in the W3C. You can contact me here.
Listing of All Posts

CSSOM Value API Proposal Dump

Last updated:

This is a basic sketch of a CSSOM Values API, where rather than returning strings for everything, we use proper JS objects. No more parsing of "5px" for you!

Requirements

  1. Must faithfully and directly reflect the property grammar in an obvious way. While specialized APIs might be useful to expose on the side, we want to be able to interact with properties in a generic way to help tooling and teaching.
  2. Must gracefully handle the kinds of property changes that the CSSWG commonly makes. DOM Level 2 Style failed this, for example, because it made shorthands return null - we pretty regularly make complex properties into shorthands, and this would break code.
  3. Must respect JS idioms. No bullshitty Java-style APIs, even if they would be more efficient. Those are just horrible, and people rightfully complain whenever we do them.
  4. Must be efficient, particularly when used in a rAF animation - we shouldn't need to create several objects (which become garbage) each iteration just to update a property.

Common Types of Property Changes

As CSS grows and changes, we alter and extend existing properties in various ways. There are a handful of common ways that have emerged over the years, though, that we must be sure to handle well in the API:

  1. Breaking apart a complex property into a shorthand with sub-properties.
  2. Turning a property with a single value into a list-valued property that accepts a comma-separated list.
  3. Adding more terms to the grammar of an existing property, such as taking a property that accepts a <length> and making it <length> || keyword, so it goes from 1 value to 1 or 2 values.

Proposal Sketch

We define an additional accessor for style data, provisionally called .css. Hanging off of this are mutable properties, one for each CSS property. (Dunno if we want to allow both camelCase and dash-case, or stick with only camelCase.)

The value of each property is an array containing one or more CSSValue objects. This has to be an array at all times, even for properties that only take a single value, like 'width', because 'width' might take two values in the future and we have to be ready for that to happen. The ordering of what values go in what indexes are defined by the spec and stable over time. Call this array a value list.

If you assign a CSSValue directly to the property, rather than to an index in the array, it's treated identically to if you'd wrapped it in an array. That is, el.css.width = 5px; and el.css.width = [5px]; are the same thing, for convenience.

The CSSValue objects (described more below) are going to be "value objects" from JS, which is a new kind of thing halfway between primitives (like numbers and strings) and objects. They're fully immutable, and it's planned that you can get syntax support for simple numeric ones, so that just appending a suffix to your number automatically makes a value object - using var x = 10px; in your JS will be equivalent to saying var x = CSS.px(10);.

For example, here's box-shadow:

el.style.boxShadow = "none";
print(el.css.boxShadow);
// []
el.style.boxShadow = "1px 1px 3px red";
print(el.css.boxShadow);
// [1px, 1px, 3px, 0px, CSS.color(red)]

If a property is list-valued, the array also has a .l property on it, which contains an array of those value lists. If you operate directly on the indexes of the value list, it's identical to operating on the first element of the .l property + unsetting all the others.

That is:

el.css.boxShadow[0] = 5px;

sets the horizontal offset of the first boxShadow and unsets all the other box shadows, identical to:

el.css.boxShadow.l[0][0] = 5px;
el.css.boxShadow.l.length = 1;

Issue: using a value list is kinda clumsy. It would be better to use named values, but that requires even more work for every property, plus more bikeshedding, and is still weird for things with a single value currently like 'width'. Maybe we can do both? Expose the value as an array, and some properties also hang individual components off of named values. Drawing from Anne's old proposal, maybe hang the named-components object off of a .m (for map) property.

Issue: .l is a crappy name. We can probably come up with better. But if we use named subvalues, we want to make sure that anything we attach to every value won't be clash with a useful name. A single letter like l seems promising for that.

Issue: Should the length of the value list and the lists of value lists be frozen? So you can manipulate values, but can't assign to indexes that don't exist? That would make things a bit saner, probably. After all, how do we serialize things if you just assign straight to el.css.backgroundImage.l[50] = ...;? Some list-valued properties may have a "null value" to use in that situation, but not all of them do. On the other hand, if we ever do the "all list-valued properties are shorthands for indexed longhands" thing, you'll be able to directly set "background-image-50" to something, so maybe we'll just have to add null values to everything anyway.

Issue: How should we handle partial updates that are invalid until completed? Just rely on the style flushing machinery to batch changes and only validate when they're flushed?

CSSValue Objects

CSSValue objects hold all of the terminal values, like <length> or <color>. The most important thing to know about them is that they're immutable. (This'll be accomplished either by deep-freezing them upon construction, or hopefully by making them from JS value objects.)

Immutability helps with efficiency, because it means the engine can cache values and reuse them in different places without you knowing - rather than your script creating a thousand different object that all represent "10px", it creates a single immutable one and uses it in a thousand places. (JS value objects let us override == and make this explicitly work.)

Immutability also cuts out a ton of potential problems from mutability. Every length, for example, is relative to the element its on, the property its on, and even the index of the value list it's in. For example, the length value in el.css.width is sometimes relative to the element's own width, for resolving percentages. What happens when you rip it off and assign it to a different property? This, and lots of related issues, disappear completely when you have immutable values.

As a consequence of this, assigning to a property value doesn't use the actual object you assigned (unless it can undetectably share). So:

el.css.width = 10em;
print(el.css.width.px); // null
print(el.css.width.em); // 10
print(el.computedCSS.width.px); // 160

el.css.width = el.computedCSS.width;
print(el.css.width.px); // 160
print(el.css.width.em); // null

(In this case, computed lengths are always "absolutized" in CSS, so we take the absolute length as the appropriate intent. Alternately, we could perhaps have computed values and beyond remember what unit their associated declared value was created with, when that's relevant. But that's a lot of difficulty and has some weird edge cases.)

CSSValue Subclasses

Each value type in CSS has a CSSValue subclass, like CSSLengthValue, CSSTimeValue, or CSSColorValue. Here's some example IDL for CSSLengthValue, one of the more complicated ones:

[Constructor(DOMString dimension),
 Constructor(double val, DOMString unit)]
interface CSSLengthValue : CSSValue { 
  readonly attribute double? px;
  readonly attribute double? pt;
  readonly attribute double? in;
  readonly attribute double? cm;
  readonly attribute double? mm;
  readonly attribute double? em;
  readonly attribute double? ex;
  readonly attribute double? vw;
  readonly attribute double? vh;
  …
};

(In addition to the direct constructors, there will be short constructor functions on the CSS object as well. Rather than new CSSLengthValue(5, 'px'), you can just write CSS.px(5).)

Depending on exactly how you obtain a length, some of the properties will be null, because the browser doesn't know how to interconvert between them.

For example, constructing a pixel length directly will let you ask for the size in pt or in, but not in em:

var l = 400px;
print(l.px) // 400
print(l.pt) // 300
print(l.in) // approximately 4.16666666
print(l.em) // null
print(l.vw) // null
print(l.percent) // null

Pulling a value directly off of a declared value is the same:

el.css.width = 400px;
print(el.css.width.px) // 400
print(el.css.width.em) // null
el.css.width = 25em;
print(el.css.width.px) // null
print(el.css.width.em) // 25

However, a computed value would have more fields filled in:

el.css.width = 400px;
print(el.computedCSS.width.px) // 400
print(el.computedCSS.width.em) // 25 (assuming font-size:16px)
print(el.computedCSS.width.vw) // 40 (assuming 1000px viewport)
print(el.computedCSS.width.percent) // null (needs layout info)

We'll need at least the following interfaces:

  • CSSLengthValue (.px, .em, .percent, etc)
  • CSSTimeValue (.s, .ms)
  • CSSAngleValue (.deg, .rad, .turn, etc)
  • CSSFrequencyValue (.hz, .khz)
  • CSSResolutionValue (.x, .dpi, etc)
  • CSSKeywordValue (.value + a stringifier?)
  • Strings are just strings
  • CSSURLValue (Would be cool if we could just use URLValue, the planned immutable/value object version of the URL interface)
  • CSSColorValue (.red, .green, .hue, etc)

Dealing With Functions

After some though, I think functions should be special-cased for each instance. We could expose a generic interface for them, similar to what we do for properties, but I don't think it'll be very useful, and the argument for a shared generic "shape" among functions is weak, unlike for properties, because other types of values are already custom.

So this means each function probably maps to a unique interface. They don't have to - all the color functions (rgb(), hsl(), etc) will all map to the CSSColorValue interface, for example - but in general they will.

They should expose their values in whatever way makes the most sense for that function. For example, the image() function will expose a list of urls/colors, plus some metadata things.

They'll also be immutable, for all the same reasons as the other values. This might call for an easy way to “mutate” one piece of a function (provide a “keypath” and a value, and return a new object with the indicated piece replaced?), but we can skip that until we're sure it's needed.

For example, here's what 'attr()' might look like:

interface CSSAttrFunctionValue : CSSValue {
  readonly attribute DOMString attribute;
  readonly attribute DOMString type;
  readonly attribute sequence<CSSValue> fallback;
};

And how it could be used:

el.dataset.width = "100";
var attr = CSS.attr('data-width', 'px', [50px])
el.css.width = attr;
print(el.css.width) // [CSS.attr(...)]
print(el.computedCSS.width) // [100px]

print(CSS.convert(attr, el)) // 100px

Note how the attr() function is preserved in the declared value, but disappears in the computed value, or when associated with an element.

Drawbacks

There are a few drawbacks to this approach.

Unless we use JS value objects (which don't exist yet), we can't do math directly on values. We can't add together two lengths:

var x = 5px;
var y = 10px;
var z = x + y; // NaN
var z = CSS.px(x.px + y.px); // success!

Nor can we do in-place updating:

el.css.width += 5px; // wrong
el.css.width = CSS.px(el.css.width.px + 5); // success!

If we wait for value objects, which I'm told we'll get Real Soon Now®, then both of these will work nicely.

Either way, though, we still won't be able to do something like:

el.css.width.px += 5;

Since the length is immutable, you can't ever assign directly to one of its members. We can't fix this without going fully mutable, which I'm not willing to do. I think it's easy enough to just construct a px value and add that to the 'width' property directly that I'm not too concerned about this.

Converting Between Values

Like I said earlier, if you construct a CSS.px(5) value, the .em property will be null, because you can't convert between px and em unless you know the font-size. In general, full interconversion requires associating the value with an element, a property, and in some cases an index into the property. (For example, a percentage in background-position is relative either to the width or the height, depending on where it appears.)

So, we need a convenient way to obtain these conversions. I think we can do something like:

CSS.convert(value, element, optional property, optional index)

This would return a new value of the same type, with all possible things filled in, based on the information you give. Example:

var x = 32px;
print(x.px);     // 32
print(x.em);     // null
print(x.percent) // null

el.css.fontSize = 16px;
var y = CSS.convert(x, el);
print(y.px)      // 32
print(y.em)      // 2
print(y.percent) // null

el.parentElement.css.width = 400px;
var z = CSS.convert(x, el, 'width');
print(z.px)      // 32
print(z.em)      // 2
print(z.percent) // 8

(a limited set of Markdown is supported)

#1 - FremyCompany:

Thanks for working on this. Still, I've an issue with this, Tab. The conversion of a value in em into px depends on the element you apply the value on. How does this work if the same object can be shared at multiple places? Moreover, values resolution actually depends on the property and element, because em resolve differently in font-size and width foe example. I didn't grasp how you plan to deal with that. Could you clarify this for me?

Reply?

(a limited set of Markdown is supported)

Re #1: Be more careful in how you're thinking about the semantics here.

You can't observe the conversion ratio between px and em unless you're querying for computed or used value. In that case, the object returned will be different for two elements if they have a different ratio:

el1.style.width = "400px";
el1.style.fontSize = "16px;
el2.style.width = "400px";
el2.style.fontSize = "20px";

print(el1.css.width.em); // null
print(el2.css.width.em); // null
// These objects are potentially shareable

print(el1.computedCSS.width.em); // 25
print(el2.computedCSS.width.em); // 20
// These objects are distinct, and thus not shareable.

Because they're immutable, you can't tell whether they're being shared or not. (Well, except through object identity? I don't recall how value objects handle that.)

Reply?

(a limited set of Markdown is supported)

#3 - FremyCompany:

Re #2:

What happens if you do el1.css.width = el2.computedCSS.width? Is "width" a special setter that will "import" the right value and return a fresh object when read? That may work.

Another thing I was wondering is: how do you handle stuff like calc(100% - 20px)? It cannot be converted to any of px or percent, but it makes sense to "add" 10px or "remove" 10% to it.

There's also the case of things like attr() or var() -- wtf of a name for a thing people at csswg keep telling are not variables but custom properties.

The question is, how do you return the value after that? Do you return an AST in this case?

Reply?

(a limited set of Markdown is supported)

Re #3:

Ah, you caught that I forget to specify that the css.foo things are indeed getter/setter pairs, not plain properties. The setter consumes your object's data but doesn't use it directly (unless, as it turns out, it can do so invisibly). I'll fix this.

(There's a non-trivial detail about what unit should be used for lengths that are fully resolved. Since we do always say that computed length are absolutized, I suppose it's fine to always take the px value.)


calc() will be a function object, not a length value. Figuring out how to expose it will be rather interesting. I suppose the convert() function might return a CSSLengthValue or similar if passed a calc function, assuming it had enough information to resolve it down.

Similar with attr() and var() - they're functions, at least in declared values. In computed values they'll be whatever they turn into.

Reply?

(a limited set of Markdown is supported)

Sorry for the off topic comment, but the layout for this comments form is badly broken in Firefox. I know you're a Chrome guy, but this has been bugging me for a while.

Reply?

(a limited set of Markdown is supported)