Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make anchor behave like native anchor when contenteditable #1684

Merged
merged 16 commits into from
Dec 11, 2023
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "Make anchor behave like native anchor when contenteditable",
"packageName": "@ni/nimble-components",
"email": "[email protected]",
"dependentChangeType": "patch"
}
33 changes: 33 additions & 0 deletions packages/nimble-components/src/anchor/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,39 @@ export class Anchor extends AnchorBase {
*/
@attr
public appearance: AnchorAppearance;

/**
* @internal
*/
public container!: HTMLElement;

public override connectedCallback(): void {
super.connectedCallback();
this.updateContentEditable();
}

/**
* @internal
* We want our anchor to behave like a native anchor when in a content-editable
* region, i.e. not operable or focusable. The most reliable way to achieve this is
* to synchronize our shadow DOM's content-editable state with that of the host.
* We must look at the host's read-only isContentEditable property, which factors
* in any inherited value. Unfortunately, there is no way for us to detect when its
* value has changed, so the best we can do is to re-sync on specific events.
* This has shortcomings, e.g. if isContentEditable goes from true to false, our
* anchor will remain un-tabable until a mouseenter triggers a re-sync.
*
* Ideally, proper support for contenteditable should come from FAST.
* I have filed bug https://github.com/microsoft/fast/issues/6870 to them.
* If/when it is fixed, we can remove our logic.
*/
public updateContentEditable(): void {
m-akinc marked this conversation as resolved.
Show resolved Hide resolved
if (this.isContentEditable) {
this.container.setAttribute('contenteditable', '');
} else {
this.container.removeAttribute('contenteditable');
}
}
}

// FoundationAnchor already applies the StartEnd mixin, so we don't need to do it here.
Expand Down
4 changes: 4 additions & 0 deletions packages/nimble-components/src/anchor/styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ export const styles = css`
font: ${linkFont};
}

.top-container {
display: contents;
}

[part='start'] {
display: none;
}
Expand Down
9 changes: 7 additions & 2 deletions packages/nimble-components/src/anchor/template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ import type { Anchor } from '.';
export const template: FoundationElementTemplate<
ViewTemplate<Anchor>,
AnchorOptions
> = (_context, definition) => html<Anchor>`<a
> = (_context, definition) => html<Anchor>`<div
m-akinc marked this conversation as resolved.
Show resolved Hide resolved
${ref('container')}
class="top-container"
><a
class="control"
part="control"
download="${x => x.download}"
Expand Down Expand Up @@ -40,6 +43,8 @@ AnchorOptions
aria-owns="${x => x.ariaOwns}"
aria-relevant="${x => x.ariaRelevant}"
aria-roledescription="${x => x.ariaRoledescription}"
@mouseenter="${x => x.updateContentEditable()}"
@focus="${x => x.updateContentEditable()}"
m-akinc marked this conversation as resolved.
Show resolved Hide resolved
${ref('control')}
>${
/* Start and End slot templates inlined to avoid extra whitespace.
Expand Down Expand Up @@ -74,4 +79,4 @@ AnchorOptions
@slotchange="${x => x.handleEndContentChange()}">
${definition.end || ''}
</slot
></span></a>`;
></span></a></div>`;
204 changes: 133 additions & 71 deletions packages/nimble-components/src/anchor/tests/anchor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,7 @@ import { waitForUpdatesAsync } from '../../testing/async-helpers';
import { fixture, Fixture } from '../../utilities/tests/fixture';
import { getSpecTypeByNamedList } from '../../utilities/tests/parameterized';

async function setup(): Promise<Fixture<Anchor>> {
return fixture<Anchor>(html`<nimble-anchor></nimble-anchor>`);
}

describe('Anchor', () => {
let element: Anchor;
let connect: () => Promise<void>;
let disconnect: () => Promise<void>;

beforeEach(async () => {
({ element, connect, disconnect } = await setup());
});

afterEach(async () => {
await disconnect();
});

it('should export its tag', () => {
expect(anchorTag).toBe('nimble-anchor');
});
Expand All @@ -29,66 +13,144 @@ describe('Anchor', () => {
expect(document.createElement('nimble-anchor')).toBeInstanceOf(Anchor);
});

it('should set the "control" class on the internal control', async () => {
await connect();
expect(element.control!.classList.contains('control')).toBe(true);
});
describe('element only', () => {
async function setup(): Promise<Fixture<Anchor>> {
return fixture<Anchor>(html`<nimble-anchor></nimble-anchor>`);
}
let element: Anchor;
let connect: () => Promise<void>;
let disconnect: () => Promise<void>;

it('should set the `part` attribute to "control" on the internal control', async () => {
await connect();
expect(element.control!.part.contains('control')).toBe(true);
});
beforeEach(async () => {
({ element, connect, disconnect } = await setup());
});

const attributeNames: { name: string }[] = [
{ name: 'download' },
{ name: 'href' },
{ name: 'hreflang' },
{ name: 'ping' },
{ name: 'referrerpolicy' },
{ name: 'rel' },
{ name: 'target' },
{ name: 'type' },
{ name: 'aria-atomic' },
{ name: 'aria-busy' },
{ name: 'aria-controls' },
{ name: 'aria-current' },
{ name: 'aria-describedby' },
{ name: 'aria-details' },
{ name: 'aria-disabled' },
{ name: 'aria-errormessage' },
{ name: 'aria-expanded' },
{ name: 'aria-flowto' },
{ name: 'aria-haspopup' },
{ name: 'aria-hidden' },
{ name: 'aria-invalid' },
{ name: 'aria-keyshortcuts' },
{ name: 'aria-label' },
{ name: 'aria-labelledby' },
{ name: 'aria-live' },
{ name: 'aria-owns' },
{ name: 'aria-relevant' },
{ name: 'aria-roledescription' }
];
describe('should reflect value to the internal control', () => {
const focused: string[] = [];
const disabled: string[] = [];
for (const attribute of attributeNames) {
const specType = getSpecTypeByNamedList(
attribute,
focused,
disabled
);
// eslint-disable-next-line @typescript-eslint/no-loop-func
specType(`for attribute ${attribute.name}`, async () => {
await connect();
afterEach(async () => {
await disconnect();
});

it('should set the "control" class on the internal control', async () => {
await connect();
expect(element.control!.classList.contains('control')).toBe(true);
});

element.setAttribute(attribute.name, 'foo');
await waitForUpdatesAsync();
it('should set the `part` attribute to "control" on the internal control', async () => {
await connect();
expect(element.control!.part.contains('control')).toBe(true);
});

expect(element.control!.getAttribute(attribute.name)).toBe(
'foo'
const attributeNames: { name: string }[] = [
{ name: 'download' },
{ name: 'href' },
{ name: 'hreflang' },
{ name: 'ping' },
{ name: 'referrerpolicy' },
{ name: 'rel' },
{ name: 'target' },
{ name: 'type' },
{ name: 'aria-atomic' },
{ name: 'aria-busy' },
{ name: 'aria-controls' },
{ name: 'aria-current' },
{ name: 'aria-describedby' },
{ name: 'aria-details' },
{ name: 'aria-disabled' },
{ name: 'aria-errormessage' },
{ name: 'aria-expanded' },
{ name: 'aria-flowto' },
{ name: 'aria-haspopup' },
{ name: 'aria-hidden' },
{ name: 'aria-invalid' },
{ name: 'aria-keyshortcuts' },
{ name: 'aria-label' },
{ name: 'aria-labelledby' },
{ name: 'aria-live' },
{ name: 'aria-owns' },
{ name: 'aria-relevant' },
{ name: 'aria-roledescription' }
];
describe('should reflect value to the internal control', () => {
const focused: string[] = [];
const disabled: string[] = [];
for (const attribute of attributeNames) {
const specType = getSpecTypeByNamedList(
attribute,
focused,
disabled
);
});
// eslint-disable-next-line @typescript-eslint/no-loop-func
specType(`for attribute ${attribute.name}`, async () => {
await connect();

element.setAttribute(attribute.name, 'foo');
await waitForUpdatesAsync();

expect(element.control!.getAttribute(attribute.name)).toBe(
'foo'
);
});
}
});
});

describe('inner anchor isContentEditable', () => {
async function setup(): Promise<Fixture<HTMLDivElement>> {
return fixture<HTMLDivElement>(
html`<div><nimble-anchor></nimble-anchor></div>`
);
}
let element: HTMLDivElement;
let anchor: Anchor;
let connect: () => Promise<void>;
let disconnect: () => Promise<void>;

beforeEach(async () => {
({ element, connect, disconnect } = await setup());
anchor = element.firstElementChild as Anchor;
});

afterEach(async () => {
await disconnect();
});

it('is false by default', async () => {
await connect();
const innerAnchor = anchor.shadowRoot!.querySelector('a')!;
expect(innerAnchor.isContentEditable).toBeFalse();
});

it('is true when container has contenteditable before connecting', async () => {
element.setAttribute('contenteditable', '');
await connect();
const innerAnchor = anchor.shadowRoot!.querySelector('a')!;
expect(innerAnchor.isContentEditable).toBeTrue();
});

it('is true when container gets contenteditable after connecting, then anchor gets mouseenter event', async () => {
await connect();
element.setAttribute('contenteditable', '');
await waitForUpdatesAsync();
const innerAnchor = anchor.shadowRoot!.querySelector('a')!;
innerAnchor.dispatchEvent(new MouseEvent('mouseenter'));
expect(innerAnchor.isContentEditable).toBeTrue();
});

it('is true when container gets contenteditable after connecting, then anchor gets focus event', async () => {
await connect();
element.setAttribute('contenteditable', '');
await waitForUpdatesAsync();
const innerAnchor = anchor.shadowRoot!.querySelector('a')!;
innerAnchor.dispatchEvent(new Event('focus'));
expect(innerAnchor.isContentEditable).toBeTrue();
});

it('is false when container loses contenteditable, then anchor gets mouseenter event', async () => {
element.setAttribute('contenteditable', '');
await connect();
element.removeAttribute('contenteditable');
const innerAnchor = anchor.shadowRoot!.querySelector('a')!;
innerAnchor.dispatchEvent(new MouseEvent('mouseenter'));
expect(innerAnchor.isContentEditable).toBeFalse();
});
});
});
Loading