Skip to content

Commit

Permalink
feat: dynamic scoped ids (#787)
Browse files Browse the repository at this point in the history
* feat: scoped dynamic id support

* chore: throw errors for css id selectors

* test: integration tests

* test: fix integration tests

* refactor: account for any type

* test: convert comments into unit tests

* chore: add logError calls as suggested

* chore: more feedback (skip tests)
  • Loading branch information
ekashida authored Nov 13, 2018
1 parent bbee654 commit e1e85cc
Show file tree
Hide file tree
Showing 33 changed files with 660 additions and 297 deletions.
200 changes: 200 additions & 0 deletions packages/lwc-engine/src/framework/__tests__/scoped-ids.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
import { createElement, LightningElement } from '../main';
import { compileTemplate } from 'test-utils';

const childHtml = compileTemplate(`<template></template>`);
class MyChild extends LightningElement {
render() {
return childHtml;
}
}

describe('scoped-ids', () => {
describe('expressions', () => {
const html = compileTemplate(`
<template>
<!--
TODO: JSDOM ends up invoking elm.setAttribute('id', value) when setting
property values, which we guard against for components by throwing. Add
coverage for custom elements when the following issue is resolved:
https://github.com/jsdom/jsdom/issues/2158
-->
<x-child></x-child>
<div id={identifier}></div>
</template>
`, {
modules: { 'x-child': MyChild }
});

describe.skip('custom elements', () => {
it('should render a transformed id attribute when its value is set to a non-empty string', () => {
class MyComponent extends LightningElement {
get identifier() {
return 'foo';
}
render() {
return html;
}
}

const elm = createElement('x-foo', { is: MyComponent });
document.body.appendChild(elm);
const child = elm.shadowRoot.querySelector('x-child');
expect(child.getAttribute('id')).toEqual(expect.stringContaining('foo'));
});

it('should render a transformed id attribute when its value is set to a boolean value', () => {
class MyComponent extends LightningElement {
get identifier() {
return true;
}
render() {
return html;
}
}

const elm = createElement('x-foo', { is: MyComponent });
document.body.appendChild(elm);
const child = elm.shadowRoot.querySelector('x-child');
expect(child.getAttribute('id')).toEqual(expect.stringContaining('true'));
});

it('should not render id attribute when its value is set to `null`', () => {
class MyComponent extends LightningElement {
get identifier() {
return null;
}
render() {
return html;
}
}

const elm = createElement('x-foo', { is: MyComponent });
document.body.appendChild(elm);
const child = elm.shadowRoot.querySelector('x-child');
expect(child.getAttribute('id')).toEqual(null);
});

it('should render expected id attribute value when its value is set to `undefined`', () => {
class MyComponent extends LightningElement {
get identifier() {
return undefined;
}
render() {
return html;
}
}

const elm = createElement('x-foo', { is: MyComponent });
expect(() => {
document.body.appendChild(elm);
}).toLogError('Invalid id value "undefined". Expected a non-empty string.');
const child = elm.shadowRoot.querySelector('x-child');
expect(child.getAttribute('id')).toEqual('undefined');
});

it('should render the id attribute as a boolean attribute when its value is set to an empty string', () => {
class MyComponent extends LightningElement {
get identifier() {
return '';
}
render() {
return html;
}
}

const elm = createElement('x-foo', { is: MyComponent });
expect(() => {
document.body.appendChild(elm);
}).toLogError('Invalid id value "". Expected a non-empty string.');
const child = elm.shadowRoot.querySelector('x-child');
expect(child.getAttribute('id')).toEqual('');
});
});

describe('native elements', () => {
it('should render a transformed id attribute when its value is set to a non-empty string', () => {
class MyComponent extends LightningElement {
get identifier() {
return 'foo';
}
render() {
return html;
}
}

const elm = createElement('x-foo', { is: MyComponent });
document.body.appendChild(elm);
const div = elm.shadowRoot.querySelector('div');
expect(div.getAttribute('id')).toEqual(expect.stringContaining('foo'));
});

it('should render a transformed id attribute when its value is set to a boolean value', () => {
class MyComponent extends LightningElement {
get identifier() {
return true;
}
render() {
return html;
}
}

const elm = createElement('x-foo', { is: MyComponent });
document.body.appendChild(elm);
const div = elm.shadowRoot.querySelector('div');
expect(div.getAttribute('id')).toEqual(expect.stringContaining('true'));
});

it('should not render id attribute when its value is set to `null`', () => {
class MyComponent extends LightningElement {
get identifier() {
return null;
}
render() {
return html;
}
}

const elm = createElement('x-foo', { is: MyComponent });
document.body.appendChild(elm);
const div = elm.shadowRoot.querySelector('div');
expect(div.getAttribute('id')).toEqual(null);
});

it('should not render id attribute when its value is set to `undefined`', () => {
class MyComponent extends LightningElement {
get identifier() {
return undefined;
}
render() {
return html;
}
}

const elm = createElement('x-foo', { is: MyComponent });
expect(() => {
document.body.appendChild(elm);
}).toLogError('Invalid id value "undefined". Expected a non-empty string.');
const div = elm.shadowRoot.querySelector('div');
expect(div.getAttribute('id')).toEqual(null);
});

it('should render the id attribute as a boolean attribute when its value is set to an empty string', () => {
class MyComponent extends LightningElement {
get identifier() {
return '';
}
render() {
return html;
}
}

const elm = createElement('x-foo', { is: MyComponent });
expect(() => {
document.body.appendChild(elm);
}).toLogError('Invalid id value "". Expected a non-empty string.');
const div = elm.shadowRoot.querySelector('div');
expect(div.getAttribute('id')).toEqual('');
});
});
});
});
11 changes: 9 additions & 2 deletions packages/lwc-engine/src/framework/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -596,6 +596,13 @@ export function k(compilerKey: number, obj: any): number | string | void {
}
}

export function gid(id: string, key: number | string): string {
return `${id}-${getCurrentOwnerId()}-${key}`;
// [g]lobal [id] function
export function gid(id: any): string | null | undefined {
if (isUndefined(id) || id === '') {
if (process.env.NODE_ENV !== 'production') {
assert.logError(`Invalid id value "${id}". Expected a non-empty string.`, vmBeingRendered!.elm);
}
return id;
}
return isNull(id) ? id : `${id}-${getCurrentOwnerId()}`;
}
41 changes: 14 additions & 27 deletions packages/lwc-errors/src/compiler/error-info/template-transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,26 +71,6 @@ export const ParserDiagnostics = {
url: ""
},

ATTRIBUTE_CANNOT_BE_EMPTY: {
code: 1001,
message: "The attribute \"{0}\" cannot be an empty string. Remove the attribute if it is unnecessary.",
level: DiagnosticLevel.Warning,
url: ""
},

ATTRIBUTE_REFERENCES_NONEXISTENT_ID: {
code: 1001,
message: "Attribute \"{0}\" references a non-existant id \"{1}\".",
level: DiagnosticLevel.Error,
url: ""
},
ATTRIBUTE_SHOULD_BE_STATIC_STRING: {
code: 1001,
message: "The attribute \"{0}\" cannot be an expression. It must be a static string value.",
level: DiagnosticLevel.Warning,
url: ""
},

BOOLEAN_ATTRIBUTE_FALSE: {
code: 1001,

Expand Down Expand Up @@ -129,6 +109,20 @@ export const ParserDiagnostics = {
url: ""
},

INVALID_ID_ATTRIBUTE: {
code: 1001,
message: "Invalid id value \"{0}\". Id values must not contain any whitespace.",
level: DiagnosticLevel.Error,
url: ""
},

INVALID_STATIC_ID_IN_ITERATION: {
code: 1001,
message: "Static id values are not allowed in iterators. Id values must be unique within a template and must therefore be computed with an expression.",
level: DiagnosticLevel.Error,
url: ""
},

DUPLICATE_ID_FOUND: {
code: 1001,
message: "Duplicate id value \"{0}\" detected. Id values must be unique within a template.",
Expand Down Expand Up @@ -241,13 +235,6 @@ export const ParserDiagnostics = {
url: ""
},

INVALID_ID_REFERENCE: {
code: 1001,
message: "Id \"{0}\" must be referenced in the template by an id-referencing attribute such as \"for\" or \"aria-describedby\".",
level: DiagnosticLevel.Warning,
url: ""
},

// TODO: consolidate with other invalid identifier error
INVALID_IDENTIFIER: {
code: 1001,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
const assert = require('assert');
const URL = 'http://localhost:4567/dynamic-scoped-ids';

describe('Scoped ids (dynamic)', () => {
before(() => {
browser.url(URL);
});

describe('should be transformed', function () {
it('aria-activedescendant', () => {
const { id, idref } = browser.execute(() => {
var integration = document.querySelector('integration-dynamic-scoped-ids');
var idElm = integration.shadowRoot.querySelector('.activedescendant-id');
var idrefElm = integration.shadowRoot.querySelector('.activedescendant-idref');
return {
id: idElm.id,
idref: idrefElm.ariaActiveDescendant,
};
}).value;
assert(id.length > 0, 'id attr should be non-empty string');
assert.notStrictEqual(id, 'activedescendant', 'id attr should be transformed');
assert.notStrictEqual(idref, 'activedescendant', 'idref attr should be transformed');
assert(id === idref, 'id attr and idref attr should be the same value');
});
it('aria-details', () => {
const { id, idref } = browser.execute(() => {
var integration = document.querySelector('integration-dynamic-scoped-ids');
var idElm = integration.shadowRoot.querySelector('.details-id');
var idrefElm = integration.shadowRoot.querySelector('.details-idref');
return {
id: idElm.id,
idref: idrefElm.ariaDetails,
};
}).value;
assert(id.length > 0, 'id attr should be non-empty string');
assert.notStrictEqual(id, 'details', 'id attr should be transformed');
assert.notStrictEqual(idref, 'details', 'idref attr should be transformed');
assert(id === idref, 'id attr and idref attr should be the same value');
});
it('aria-errormessage', () => {
const { id, idref } = browser.execute(() => {
var integration = document.querySelector('integration-dynamic-scoped-ids');
var idElm = integration.shadowRoot.querySelector('.errormessage-id');
var idrefElm = integration.shadowRoot.querySelector('.errormessage-idref');
return {
id: idElm.id,
idref: idrefElm.ariaErrorMessage,
};
}).value;
assert(id.length > 0, 'id attr should be non-empty string');
assert.notStrictEqual(id, 'errormessage', 'id attr should be transformed');
assert.notStrictEqual(idref, 'errormessage', 'idref attr should be transformed');
assert(id === idref, 'id attr and idref attr should be the same value');
});
it('aria-flowto', () => {
const { id, idref } = browser.execute(() => {
var integration = document.querySelector('integration-dynamic-scoped-ids');
var idElm = integration.shadowRoot.querySelector('.flowto-id');
var idrefElm = integration.shadowRoot.querySelector('.flowto-idref');
return {
id: idElm.id,
idref: idrefElm.ariaFlowTo,
};
}).value;
assert(id.length > 0, 'id attr should be non-empty string');
assert.notStrictEqual(id, 'flowto', 'id attr should be transformed');
assert.notStrictEqual(idref, 'flowto', 'idref attr should be transformed');
assert(id === idref, 'id attr and idref attr should be the same value');
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<template>
<div>
<div id={activedescendant} class="activedescendant-id">activedescendant-id</div>
<div aria-activedescendant={activedescendant} class="activedescendant-idref">activedescendant-idref</div>
</div>
<div>
<div id={controls} class="controls-id">controls-id</div>
<div aria-controls={controls} class="controls-idref">controls-idref</div>
</div>
<div>
<div id={describedby} class="describedby-id">describedby-id</div>
<div aria-describedby={describedby} class="describedby-idref">describedby-idref</div>
</div>
<div>
<div id={details} class="details-id">details-id</div>
<div aria-details={details} class="details-idref">details-idref</div>
</div>
<div>
<div aria-errormessage={errormessage} class="errormessage-idref">errormessage-idref</div>
<div id={errormessage} class="errormessage-id">errormessage-id</div>
</div>
<div>
<div id={flowto} class="flowto-id">flowto-id</div>
<div aria-flowto={flowto} class="flowto-idref">flowto-idref</div>
</div>
<div>
<div id={labelledby} class="labelledby-id">labelledby-id</div>
<div aria-labelledby={labelledby} class="labelledby-idref">labelledby-idref</div>
</div>
<div>
<div id={owns} class="owns-id">owns-id</div>
<div aria-owns={owns} class="owns-idref">owns-idref</div>
</div>
<div>
<label for={forfor} class="for-idref">for-idref</label>
<div id={forfor} class="for-id">for-id</div>
</div>
</template>
Loading

0 comments on commit e1e85cc

Please sign in to comment.