@ax-design/typed-om 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,7 +1,12 @@
1
- <h1 align="center">Typed OM</h1>
1
+ <h1 align="center">
2
+ <picture>
3
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/ax-design/typed-om/master/docs/logo-r.png">
4
+ <img src="https://raw.githubusercontent.com/ax-design/typed-om/master/docs/logo.png" alt="Ax Typed OM">
5
+ </picture>
6
+ </h1>
2
7
 
3
8
  <p align="center">
4
- Typed reads and change detection of CSS custom properties for Axiom Design System, with or without CSS Typed OM.
9
+ Typed reads and monitors CSS custom properties for Axiom Design System.
5
10
  </p>
6
11
 
7
12
  <p align="center">
@@ -1,4 +1,11 @@
1
1
  import type { PropertySpec } from './schema.js';
2
+ export interface KeywordMirror {
3
+ name: string;
4
+ property: string;
5
+ syntax: string;
6
+ initial: string;
7
+ }
8
+ export declare function keywordMirrors(schema: PropertySpec[]): KeywordMirror[];
2
9
  export interface DetectorTemplate {
3
10
  css: string;
4
11
  elementCount: number;
package/dist/detector.js CHANGED
@@ -30,13 +30,45 @@ function chunk(items, size) {
30
30
  function weight(i) {
31
31
  return (1 + i * 0.0173).toFixed(4);
32
32
  }
33
+ // Numeric specs that may hold a keyword (e.g. "<length> | auto") would poison the numeric
34
+ // calc() hash when the computed value is that keyword: calc(auto * 1.0000) is invalid and
35
+ // invalidates the whole transform-origin / perspective-origin declaration. Give each such spec
36
+ // a registered mirror with a pure numeric syntax: "auto" is rejected at computed-value time,
37
+ // so the mirror falls back to its zero initial and the hash stays legal. Value<->keyword jumps
38
+ // still move the hash (number -> 0), so changes keep firing.
39
+ export function keywordMirrors(schema) {
40
+ const out = [];
41
+ let i = 0;
42
+ for (const spec of schema) {
43
+ const t = spec.syntax.type;
44
+ if (t !== 'length' && t !== 'length-percentage' && t !== 'number')
45
+ continue;
46
+ if (!spec.syntax.keywords || !spec.syntax.keywords.length)
47
+ continue;
48
+ out.push({
49
+ name: `--${PREFIX}-kw-m${i++}`,
50
+ property: spec.name,
51
+ syntax: t === 'number' ? '<number>' : t === 'length' ? '<length>' : '<length-percentage>',
52
+ initial: t === 'number' ? '0' : '0px',
53
+ });
54
+ }
55
+ return out;
56
+ }
33
57
  // `registered` lists the properties registered by name. A registered non-inherited property
34
58
  // does not reach the detector element inside the shadow root, so the host mirrors it into an
35
59
  // unregistered (inherited) property. The slot resets the mirrors so author content does not
36
60
  // inherit them.
37
61
  export function buildDetector(schema, registered = new Set()) {
38
62
  const mirrors = [];
63
+ const kwMirrors = [];
64
+ const kwName = {};
65
+ for (const m of keywordMirrors(schema)) {
66
+ kwName[m.property] = m.name;
67
+ kwMirrors.push(`${m.name}: var(${m.property});`);
68
+ }
39
69
  const source = (spec) => {
70
+ if (kwName[spec.name])
71
+ return kwName[spec.name];
40
72
  if (!registered.has(spec.name) || spec.inherits)
41
73
  return spec.name;
42
74
  const mirror = `--${PREFIX}-m${mirrors.length}`;
@@ -52,7 +84,11 @@ export function buildDetector(schema, registered = new Set()) {
52
84
  const groups = chunk(numeric, Math.ceil(numeric.length / HASH_SLOTS));
53
85
  const hashes = groups.map((group) => `calc(${group
54
86
  .map((spec, i) => {
55
- const v = `var(${source(spec)}, ${spec.initial})`;
87
+ const src = source(spec);
88
+ // The registered mirror always resolves to a number (keyword -> zero), so a
89
+ // keyword-capable spec's fallback is zero rather than its (keyword) initial.
90
+ const fallback = kwName[spec.name] ? (spec.syntax.type === 'number' ? '0' : '0px') : spec.initial;
91
+ const v = `var(${src}, ${fallback})`;
56
92
  return spec.syntax.type === 'number' ? `${v} * ${weight(i)}px` : `${v} * ${weight(i)}`;
57
93
  })
58
94
  .join(' + ')})`);
@@ -94,9 +130,10 @@ export function buildDetector(schema, registered = new Set()) {
94
130
  const transition = e.transitions.length ? ` transition: ${e.transitions.join(', ')};` : '';
95
131
  rules.push(`.${PREFIX}-d${i} { ${e.decls.join(' ')}${transition} }`);
96
132
  });
97
- if (mirrors.length) {
98
- rules.push(`:host { ${mirrors.join(' ')} }`);
99
- rules.push(`slot { ${mirrors.map((m) => `${m.slice(0, m.indexOf(':'))}: initial;`).join(' ')} }`);
133
+ if (mirrors.length || kwMirrors.length) {
134
+ const all = [...mirrors, ...kwMirrors];
135
+ rules.push(`:host { ${all.join(' ')} }`);
136
+ rules.push(`slot { ${all.map((m) => `${m.slice(0, m.indexOf(':'))}: initial;`).join(' ')} }`);
100
137
  }
101
138
  rules.push(`.${PREFIX}-c { position: absolute; left: 0; top: 0; width: 1px; height: 1px; overflow: hidden; visibility: hidden; pointer-events: none; contain: strict; }`, `.${PREFIX}-c > div { position: absolute; left: 0; top: 0; width: 1px; height: 1px; }`);
102
139
  return { css: rules.join('\n'), elementCount: elements.length };
package/dist/reader.js CHANGED
@@ -41,10 +41,16 @@ export class StyleReader {
41
41
  function parseFast(spec, text) {
42
42
  switch (spec.syntax.type) {
43
43
  case 'length':
44
+ if (spec.syntax.keywords && spec.syntax.keywords.indexOf(text) >= 0)
45
+ return text;
44
46
  return parseLength(text);
45
47
  case 'length-percentage':
48
+ if (spec.syntax.keywords && spec.syntax.keywords.indexOf(text) >= 0)
49
+ return text;
46
50
  return parseLengthPercentage(text);
47
51
  case 'number':
52
+ if (spec.syntax.keywords && spec.syntax.keywords.indexOf(text) >= 0)
53
+ return text;
48
54
  return parseNumber(text);
49
55
  case 'color':
50
56
  if (spec.syntax.keywords && spec.syntax.keywords.indexOf(text) >= 0)
package/dist/register.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { keywordMirrors } from './detector.js';
1
2
  import { cssSyntax } from './schema.js';
2
3
  export function canRegister() {
3
4
  return typeof CSS !== 'undefined' && typeof CSS.registerProperty === 'function';
@@ -25,5 +26,19 @@ export function registerProperties(schema) {
25
26
  throw error;
26
27
  }
27
28
  }
29
+ // Numeric specs that may hold a keyword get a registered mirror with a pure numeric syntax,
30
+ // so the keyword is rejected at computed-value time and the numeric hash stays legal. The
31
+ // mirror is internal (never read by the reader) and its definition is derived from the
32
+ // permanent schema contract, so a re-registration by another copy is the same definition.
33
+ for (const m of keywordMirrors(schema)) {
34
+ try {
35
+ css.registerProperty({ name: m.name, syntax: m.syntax, inherits: true, initialValue: m.initial });
36
+ }
37
+ catch (error) {
38
+ // Already registered by an earlier copy with the identical definition.
39
+ if (!error || error.name !== 'InvalidModificationError')
40
+ throw error;
41
+ }
42
+ }
28
43
  return registered;
29
44
  }
package/dist/schema.d.ts CHANGED
@@ -1,9 +1,12 @@
1
1
  export type PropertySyntax = {
2
2
  type: 'length';
3
+ keywords?: string[];
3
4
  } | {
4
5
  type: 'length-percentage';
6
+ keywords?: string[];
5
7
  } | {
6
8
  type: 'number';
9
+ keywords?: string[];
7
10
  } | {
8
11
  type: 'color';
9
12
  keywords?: string[];
package/dist/schema.js CHANGED
@@ -1,4 +1,12 @@
1
1
  const RESERVED = ['initial', 'inherit', 'unset', 'revert', 'revert-layer', 'default'];
2
+ function checkKeywords(name, keywords) {
3
+ for (const value of keywords) {
4
+ if (RESERVED.indexOf(value) >= 0)
5
+ throw new Error(`${name}: keyword "${value}" cannot be observed`);
6
+ if (!/^-?[_a-zA-Z][_a-zA-Z0-9-]*$/.test(value))
7
+ throw new Error(`${name}: "${value}" is not an identifier`);
8
+ }
9
+ }
2
10
  export function validateSchema(schema) {
3
11
  const seen = [];
4
12
  for (const spec of schema) {
@@ -8,24 +16,25 @@ export function validateSchema(schema) {
8
16
  seen.push(spec.name);
9
17
  if (spec.name.indexOf('--') !== 0)
10
18
  throw new Error(`${spec.name} is not a custom property name`);
11
- if (spec.syntax.type !== 'keyword')
12
- continue;
13
- for (const value of spec.syntax.values) {
14
- if (RESERVED.indexOf(value) >= 0)
15
- throw new Error(`${spec.name}: keyword "${value}" cannot be observed`);
16
- if (!/^-?[_a-zA-Z][_a-zA-Z0-9-]*$/.test(value))
17
- throw new Error(`${spec.name}: "${value}" is not an identifier`);
19
+ const syntax = spec.syntax;
20
+ if (syntax.type === 'keyword') {
21
+ checkKeywords(spec.name, syntax.values);
22
+ }
23
+ else if (syntax.type !== 'color' && syntax.keywords) {
24
+ // Numeric types may hold a keyword instead of a value ("<length> | auto");
25
+ // validate them the same way as the keyword type. Color keywords stay unvalidated.
26
+ checkKeywords(spec.name, syntax.keywords);
18
27
  }
19
28
  }
20
29
  }
21
30
  export function cssSyntax(syntax) {
22
31
  switch (syntax.type) {
23
32
  case 'length':
24
- return '<length>';
33
+ return syntax.keywords && syntax.keywords.length ? `<length> | ${syntax.keywords.join(' | ')}` : '<length>';
25
34
  case 'length-percentage':
26
- return '<length-percentage>';
35
+ return syntax.keywords && syntax.keywords.length ? `<length-percentage> | ${syntax.keywords.join(' | ')}` : '<length-percentage>';
27
36
  case 'number':
28
- return '<number>';
37
+ return syntax.keywords && syntax.keywords.length ? `<number> | ${syntax.keywords.join(' | ')}` : '<number>';
29
38
  case 'color':
30
39
  return syntax.keywords && syntax.keywords.length ? `<color> | ${syntax.keywords.join(' | ')}` : '<color>';
31
40
  case 'keyword':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ax-design/typed-om",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Typed reads and change detection for CSS custom properties, for browsers with or without CSS Typed OM and @property.",
5
5
  "keywords": [
6
6
  "css",
package/src/detector.ts CHANGED
@@ -35,6 +35,40 @@ function weight(i: number): string {
35
35
  return (1 + i * 0.0173).toFixed(4);
36
36
  }
37
37
 
38
+ export interface KeywordMirror {
39
+ // Name of the registered mirror property, e.g. "--ax-typed-om-kw-m0".
40
+ name: string;
41
+ // The source custom property the mirror reflects.
42
+ property: string;
43
+ // Pure numeric syntax (no keywords) the mirror is registered with.
44
+ syntax: string;
45
+ // Zero initial value for that syntax.
46
+ initial: string;
47
+ }
48
+
49
+ // Numeric specs that may hold a keyword (e.g. "<length> | auto") would poison the numeric
50
+ // calc() hash when the computed value is that keyword: calc(auto * 1.0000) is invalid and
51
+ // invalidates the whole transform-origin / perspective-origin declaration. Give each such spec
52
+ // a registered mirror with a pure numeric syntax: "auto" is rejected at computed-value time,
53
+ // so the mirror falls back to its zero initial and the hash stays legal. Value<->keyword jumps
54
+ // still move the hash (number -> 0), so changes keep firing.
55
+ export function keywordMirrors(schema: PropertySpec[]): KeywordMirror[] {
56
+ const out: KeywordMirror[] = [];
57
+ let i = 0;
58
+ for (const spec of schema) {
59
+ const t = spec.syntax.type;
60
+ if (t !== 'length' && t !== 'length-percentage' && t !== 'number') continue;
61
+ if (!spec.syntax.keywords || !spec.syntax.keywords.length) continue;
62
+ out.push({
63
+ name: `--${PREFIX}-kw-m${i++}`,
64
+ property: spec.name,
65
+ syntax: t === 'number' ? '<number>' : t === 'length' ? '<length>' : '<length-percentage>',
66
+ initial: t === 'number' ? '0' : '0px',
67
+ });
68
+ }
69
+ return out;
70
+ }
71
+
38
72
  export interface DetectorTemplate {
39
73
  css: string;
40
74
  elementCount: number;
@@ -46,7 +80,14 @@ export interface DetectorTemplate {
46
80
  // inherit them.
47
81
  export function buildDetector(schema: PropertySpec[], registered: Set<string> = new Set()): DetectorTemplate {
48
82
  const mirrors: string[] = [];
83
+ const kwMirrors: string[] = [];
84
+ const kwName: { [name: string]: string } = {};
85
+ for (const m of keywordMirrors(schema)) {
86
+ kwName[m.property] = m.name;
87
+ kwMirrors.push(`${m.name}: var(${m.property});`);
88
+ }
49
89
  const source = (spec: PropertySpec) => {
90
+ if (kwName[spec.name]) return kwName[spec.name];
50
91
  if (!registered.has(spec.name) || spec.inherits) return spec.name;
51
92
  const mirror = `--${PREFIX}-m${mirrors.length}`;
52
93
  mirrors.push(`${mirror}: var(${spec.name});`);
@@ -64,7 +105,11 @@ export function buildDetector(schema: PropertySpec[], registered: Set<string> =
64
105
  const hashes = groups.map((group) =>
65
106
  `calc(${group
66
107
  .map((spec, i) => {
67
- const v = `var(${source(spec)}, ${spec.initial})`;
108
+ const src = source(spec);
109
+ // The registered mirror always resolves to a number (keyword -> zero), so a
110
+ // keyword-capable spec's fallback is zero rather than its (keyword) initial.
111
+ const fallback = kwName[spec.name] ? (spec.syntax.type === 'number' ? '0' : '0px') : spec.initial;
112
+ const v = `var(${src}, ${fallback})`;
68
113
  return spec.syntax.type === 'number' ? `${v} * ${weight(i)}px` : `${v} * ${weight(i)}`;
69
114
  })
70
115
  .join(' + ')})`
@@ -106,9 +151,10 @@ export function buildDetector(schema: PropertySpec[], registered: Set<string> =
106
151
  const transition = e.transitions.length ? ` transition: ${e.transitions.join(', ')};` : '';
107
152
  rules.push(`.${PREFIX}-d${i} { ${e.decls.join(' ')}${transition} }`);
108
153
  });
109
- if (mirrors.length) {
110
- rules.push(`:host { ${mirrors.join(' ')} }`);
111
- rules.push(`slot { ${mirrors.map((m) => `${m.slice(0, m.indexOf(':'))}: initial;`).join(' ')} }`);
154
+ if (mirrors.length || kwMirrors.length) {
155
+ const all = [...mirrors, ...kwMirrors];
156
+ rules.push(`:host { ${all.join(' ')} }`);
157
+ rules.push(`slot { ${all.map((m) => `${m.slice(0, m.indexOf(':'))}: initial;`).join(' ')} }`);
112
158
  }
113
159
  rules.push(
114
160
  `.${PREFIX}-c { position: absolute; left: 0; top: 0; width: 1px; height: 1px; overflow: hidden; visibility: hidden; pointer-events: none; contain: strict; }`,
package/src/reader.ts CHANGED
@@ -54,10 +54,13 @@ export class StyleReader {
54
54
  function parseFast(spec: PropertySpec, text: string): PropertyValue {
55
55
  switch (spec.syntax.type) {
56
56
  case 'length':
57
+ if (spec.syntax.keywords && spec.syntax.keywords.indexOf(text) >= 0) return text;
57
58
  return parseLength(text);
58
59
  case 'length-percentage':
60
+ if (spec.syntax.keywords && spec.syntax.keywords.indexOf(text) >= 0) return text;
59
61
  return parseLengthPercentage(text);
60
62
  case 'number':
63
+ if (spec.syntax.keywords && spec.syntax.keywords.indexOf(text) >= 0) return text;
61
64
  return parseNumber(text);
62
65
  case 'color':
63
66
  if (spec.syntax.keywords && spec.syntax.keywords.indexOf(text) >= 0) return text;
package/src/register.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { keywordMirrors } from './detector.js';
1
2
  import { cssSyntax } from './schema.js';
2
3
  import type { PropertySpec } from './schema.js';
3
4
 
@@ -32,5 +33,17 @@ export function registerProperties(schema: PropertySpec[]): Set<string> {
32
33
  else throw error;
33
34
  }
34
35
  }
36
+ // Numeric specs that may hold a keyword get a registered mirror with a pure numeric syntax,
37
+ // so the keyword is rejected at computed-value time and the numeric hash stays legal. The
38
+ // mirror is internal (never read by the reader) and its definition is derived from the
39
+ // permanent schema contract, so a re-registration by another copy is the same definition.
40
+ for (const m of keywordMirrors(schema)) {
41
+ try {
42
+ css.registerProperty!({ name: m.name, syntax: m.syntax, inherits: true, initialValue: m.initial });
43
+ } catch (error) {
44
+ // Already registered by an earlier copy with the identical definition.
45
+ if (!error || (error as Error).name !== 'InvalidModificationError') throw error;
46
+ }
47
+ }
35
48
  return registered;
36
49
  }
package/src/schema.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  // Description of one custom property the caller wants to read and observe.
2
2
  export type PropertySyntax =
3
- | { type: 'length' }
4
- | { type: 'length-percentage' }
5
- | { type: 'number' }
3
+ | { type: 'length'; keywords?: string[] }
4
+ | { type: 'length-percentage'; keywords?: string[] }
5
+ | { type: 'number'; keywords?: string[] }
6
6
  // `keywords`: extra identifiers the property may hold instead of a color (returned as strings).
7
7
  | { type: 'color'; keywords?: string[] }
8
8
  // Allowed values. CSS-wide keywords are rejected. `none` is allowed for existing APIs, but
@@ -37,6 +37,13 @@ export type PropertyValue = LengthPercentage | number | string | Rgba | null;
37
37
 
38
38
  const RESERVED = ['initial', 'inherit', 'unset', 'revert', 'revert-layer', 'default'];
39
39
 
40
+ function checkKeywords(name: string, keywords: string[]): void {
41
+ for (const value of keywords) {
42
+ if (RESERVED.indexOf(value) >= 0) throw new Error(`${name}: keyword "${value}" cannot be observed`);
43
+ if (!/^-?[_a-zA-Z][_a-zA-Z0-9-]*$/.test(value)) throw new Error(`${name}: "${value}" is not an identifier`);
44
+ }
45
+ }
46
+
40
47
  export function validateSchema(schema: PropertySpec[]): void {
41
48
  const seen: string[] = [];
42
49
  for (const spec of schema) {
@@ -45,10 +52,13 @@ export function validateSchema(schema: PropertySpec[]): void {
45
52
  }
46
53
  seen.push(spec.name);
47
54
  if (spec.name.indexOf('--') !== 0) throw new Error(`${spec.name} is not a custom property name`);
48
- if (spec.syntax.type !== 'keyword') continue;
49
- for (const value of spec.syntax.values) {
50
- if (RESERVED.indexOf(value) >= 0) throw new Error(`${spec.name}: keyword "${value}" cannot be observed`);
51
- if (!/^-?[_a-zA-Z][_a-zA-Z0-9-]*$/.test(value)) throw new Error(`${spec.name}: "${value}" is not an identifier`);
55
+ const syntax = spec.syntax;
56
+ if (syntax.type === 'keyword') {
57
+ checkKeywords(spec.name, syntax.values);
58
+ } else if (syntax.type !== 'color' && syntax.keywords) {
59
+ // Numeric types may hold a keyword instead of a value ("<length> | auto");
60
+ // validate them the same way as the keyword type. Color keywords stay unvalidated.
61
+ checkKeywords(spec.name, syntax.keywords);
52
62
  }
53
63
  }
54
64
  }
@@ -56,11 +66,11 @@ export function validateSchema(schema: PropertySpec[]): void {
56
66
  export function cssSyntax(syntax: PropertySyntax): string {
57
67
  switch (syntax.type) {
58
68
  case 'length':
59
- return '<length>';
69
+ return syntax.keywords && syntax.keywords.length ? `<length> | ${syntax.keywords.join(' | ')}` : '<length>';
60
70
  case 'length-percentage':
61
- return '<length-percentage>';
71
+ return syntax.keywords && syntax.keywords.length ? `<length-percentage> | ${syntax.keywords.join(' | ')}` : '<length-percentage>';
62
72
  case 'number':
63
- return '<number>';
73
+ return syntax.keywords && syntax.keywords.length ? `<number> | ${syntax.keywords.join(' | ')}` : '<number>';
64
74
  case 'color':
65
75
  return syntax.keywords && syntax.keywords.length ? `<color> | ${syntax.keywords.join(' | ')}` : '<color>';
66
76
  case 'keyword':