diff --git a/angular-workspace/projects/ni/nimble-angular/src/directives/anchor/nimble-anchor.directive.ts b/angular-workspace/projects/ni/nimble-angular/src/directives/anchor/nimble-anchor.directive.ts index 12d4e02762..7e3681ed95 100644 --- a/angular-workspace/projects/ni/nimble-angular/src/directives/anchor/nimble-anchor.directive.ts +++ b/angular-workspace/projects/ni/nimble-angular/src/directives/anchor/nimble-anchor.directive.ts @@ -33,6 +33,16 @@ export class NimbleAnchorDirective extends NimbleAnchorBaseDirective { this.renderer.setProperty(this.elementRef.nativeElement, 'underlineHidden', toBooleanProperty(value)); } + public get contentEditable(): string { + return this.elementRef.nativeElement.contentEditable; + } + + // Renaming because property should have camel casing, but attribute should not + // eslint-disable-next-line @angular-eslint/no-input-rename + @Input('contenteditable') public set contentEditable(value: string) { + this.renderer.setProperty(this.elementRef.nativeElement, 'contentEditable', value); + } + public constructor(renderer: Renderer2, elementRef: ElementRef) { super(renderer, elementRef); } diff --git a/angular-workspace/projects/ni/nimble-angular/src/directives/anchor/tests/nimble-anchor.directive.spec.ts b/angular-workspace/projects/ni/nimble-angular/src/directives/anchor/tests/nimble-anchor.directive.spec.ts index 1cede0e2f6..5b98299f43 100644 --- a/angular-workspace/projects/ni/nimble-angular/src/directives/anchor/tests/nimble-anchor.directive.spec.ts +++ b/angular-workspace/projects/ni/nimble-angular/src/directives/anchor/tests/nimble-anchor.directive.spec.ts @@ -101,6 +101,11 @@ describe('Nimble anchor', () => { expect(directive.underlineHidden).toBeFalse(); expect(nativeElement.underlineHidden).toBeFalse(); }); + + it('has expected defaults for contentEditable', () => { + expect(directive.contentEditable).toBeUndefined(); + expect(nativeElement.contentEditable).toBeUndefined(); + }); }); describe('with template string values', () => { @@ -116,6 +121,7 @@ describe('Nimble anchor', () => { type="${type1}" appearance="prominent" underline-hidden + contenteditable="true" > ` @@ -184,6 +190,11 @@ describe('Nimble anchor', () => { expect(directive.underlineHidden).toBeTrue(); expect(nativeElement.underlineHidden).toBeTrue(); }); + + it('will use template string values for contentEditable', () => { + expect(directive.contentEditable).toEqual('true'); + expect(nativeElement.contentEditable).toEqual('true'); + }); }); describe('with property bound values', () => { @@ -199,6 +210,7 @@ describe('Nimble anchor', () => { [type]="type" [appearance]="appearance" [underlineHidden]="underlineHidden" + [contentEditable]="contentEditable" > ` @@ -215,6 +227,7 @@ describe('Nimble anchor', () => { public type = type1; public appearance: AnchorAppearance = appearance1; public underlineHidden = true; + public contentEditable = 'true'; } let fixture: ComponentFixture; @@ -330,6 +343,17 @@ describe('Nimble anchor', () => { expect(directive.underlineHidden).toBeFalse(); expect(nativeElement.underlineHidden).toBeFalse(); }); + + it('can be configured with property binding for contentEditable', () => { + expect(directive.contentEditable).toEqual('true'); + expect(nativeElement.contentEditable).toEqual('true'); + + fixture.componentInstance.contentEditable = 'false'; + fixture.detectChanges(); + + expect(directive.contentEditable).toEqual('false'); + expect(nativeElement.contentEditable).toEqual('false'); + }); }); describe('with attribute bound values', () => { @@ -345,6 +369,7 @@ describe('Nimble anchor', () => { [attr.type]="type" [attr.appearance]="appearance" [attr.underline-hidden]="underlineHidden" + [attr.contenteditable]="contentEditable" > ` @@ -361,6 +386,7 @@ describe('Nimble anchor', () => { public type = type1; public appearance: AnchorAppearance = appearance1; public underlineHidden = true; + public contentEditable = 'true'; } let fixture: ComponentFixture; @@ -476,5 +502,16 @@ describe('Nimble anchor', () => { expect(directive.underlineHidden).toBeFalse(); expect(nativeElement.underlineHidden).toBeFalse(); }); + + it('can be configured with attribute binding for contentEditable', () => { + expect(directive.contentEditable).toEqual('true'); + expect(nativeElement.contentEditable).toEqual('true'); + + fixture.componentInstance.contentEditable = 'false'; + fixture.detectChanges(); + + expect(directive.contentEditable).toEqual('false'); + expect(nativeElement.contentEditable).toEqual('false'); + }); }); }); diff --git a/change/@ni-nimble-angular-af4cc1a5-1c53-4eac-8cba-b38dcd8fe376.json b/change/@ni-nimble-angular-af4cc1a5-1c53-4eac-8cba-b38dcd8fe376.json new file mode 100644 index 0000000000..58e26763f8 --- /dev/null +++ b/change/@ni-nimble-angular-af4cc1a5-1c53-4eac-8cba-b38dcd8fe376.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "Add contentEditable property to anchor directive", + "packageName": "@ni/nimble-angular", + "email": "7282195+m-akinc@users.noreply.github.com", + "dependentChangeType": "patch" +} diff --git a/change/@ni-nimble-blazor-21d307d5-1145-4e6c-8be4-dd65b8ca424c.json b/change/@ni-nimble-blazor-21d307d5-1145-4e6c-8be4-dd65b8ca424c.json new file mode 100644 index 0000000000..03bceea747 --- /dev/null +++ b/change/@ni-nimble-blazor-21d307d5-1145-4e6c-8be4-dd65b8ca424c.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "Add ContentEditable property to anchor", + "packageName": "@ni/nimble-blazor", + "email": "7282195+m-akinc@users.noreply.github.com", + "dependentChangeType": "patch" +} diff --git a/change/@ni-nimble-components-be72d648-96c1-403d-80e8-2a68c3752207.json b/change/@ni-nimble-components-be72d648-96c1-403d-80e8-2a68c3752207.json new file mode 100644 index 0000000000..dd7415a680 --- /dev/null +++ b/change/@ni-nimble-components-be72d648-96c1-403d-80e8-2a68c3752207.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "Make anchor behave like native anchor when contenteditable", + "packageName": "@ni/nimble-components", + "email": "7282195+m-akinc@users.noreply.github.com", + "dependentChangeType": "patch" +} diff --git a/packages/nimble-blazor/NimbleBlazor/Components/NimbleAnchor.razor b/packages/nimble-blazor/NimbleBlazor/Components/NimbleAnchor.razor index 6a426d7260..2a942cdfb4 100644 --- a/packages/nimble-blazor/NimbleBlazor/Components/NimbleAnchor.razor +++ b/packages/nimble-blazor/NimbleBlazor/Components/NimbleAnchor.razor @@ -9,6 +9,7 @@ target="@Target" type="@Type" underline-hidden="@UnderlineHidden" + contenteditable="@ContentEditable" appearance="@Appearance.ToAttributeValue()" @attributes="AdditionalAttributes"> @ChildContent diff --git a/packages/nimble-blazor/NimbleBlazor/Components/NimbleAnchor.razor.cs b/packages/nimble-blazor/NimbleBlazor/Components/NimbleAnchor.razor.cs index 9d5d59fe6d..1c89bee5aa 100644 --- a/packages/nimble-blazor/NimbleBlazor/Components/NimbleAnchor.razor.cs +++ b/packages/nimble-blazor/NimbleBlazor/Components/NimbleAnchor.razor.cs @@ -10,6 +10,12 @@ public partial class NimbleAnchor : NimbleAnchorBase [Parameter] public bool? UnderlineHidden { get; set; } + /// + /// Whether the anchor should behave like it is in an editable region. + /// + [Parameter] + public string? ContentEditable { get; set; } + /// /// The appearance of the anchor. /// diff --git a/packages/nimble-blazor/Tests/NimbleBlazor.Tests/Unit/Components/NimbleAnchorTests.cs b/packages/nimble-blazor/Tests/NimbleBlazor.Tests/Unit/Components/NimbleAnchorTests.cs index 7fa9e48005..76c04176fd 100644 --- a/packages/nimble-blazor/Tests/NimbleBlazor.Tests/Unit/Components/NimbleAnchorTests.cs +++ b/packages/nimble-blazor/Tests/NimbleBlazor.Tests/Unit/Components/NimbleAnchorTests.cs @@ -41,6 +41,22 @@ public void AnchorAppearance_AttributeIsSet(AnchorAppearance value, string expec Assert.Contains(expectedMarkup, anchor.Markup); } + [Fact] + public void AnchorContentEditable_AttributeIsSet() + { + var anchor = RenderWithPropertySet(x => x.ContentEditable, "true"); + + Assert.Contains("contenteditable=\"true\"", anchor.Markup); + } + + [Fact] + public void AnchorUnderlineHidden_AttributeIsSet() + { + var anchor = RenderWithPropertySet(x => x.UnderlineHidden, true); + + Assert.Contains("underline-hidden", anchor.Markup); + } + private IRenderedComponent RenderWithPropertySet(Expression> propertyGetter, TProperty propertyValue) { var context = new TestContext(); diff --git a/packages/nimble-components/src/anchor/index.ts b/packages/nimble-components/src/anchor/index.ts index 9ddffbca88..82c05f85e5 100644 --- a/packages/nimble-components/src/anchor/index.ts +++ b/packages/nimble-components/src/anchor/index.ts @@ -34,6 +34,19 @@ export class Anchor extends AnchorBase { */ @attr public appearance: AnchorAppearance; + + /** + * @public + * @remarks + * HTML Attribute: contenteditable + * See https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/contenteditable + * + * 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 this workaround. + */ + @attr({ attribute: 'contenteditable' }) + public override contentEditable!: string; } // FoundationAnchor already applies the StartEnd mixin, so we don't need to do it here. diff --git a/packages/nimble-components/src/anchor/styles.ts b/packages/nimble-components/src/anchor/styles.ts index 8c225dee73..9d1f1a9c0e 100644 --- a/packages/nimble-components/src/anchor/styles.ts +++ b/packages/nimble-components/src/anchor/styles.ts @@ -20,6 +20,10 @@ export const styles = css` font: ${linkFont}; } + .top-container { + display: contents; + } + [part='start'] { display: none; } diff --git a/packages/nimble-components/src/anchor/template.ts b/packages/nimble-components/src/anchor/template.ts index 862228536f..13cd273f22 100644 --- a/packages/nimble-components/src/anchor/template.ts +++ b/packages/nimble-components/src/anchor/template.ts @@ -9,7 +9,14 @@ import type { Anchor } from '.'; export const template: FoundationElementTemplate< ViewTemplate, AnchorOptions -> = (_context, definition) => html` = (_context, definition) => html`${ + /* top-container div is necessary because setting contenteditable directly on the native anchor instead + leaves it focusable, unlike the behavior you get when the anchor is _within_ a contenteditable element. + */ '' +}`; diff --git a/packages/nimble-components/src/anchor/tests/anchor.spec.ts b/packages/nimble-components/src/anchor/tests/anchor.spec.ts index 3a16cee610..cac1096879 100644 --- a/packages/nimble-components/src/anchor/tests/anchor.spec.ts +++ b/packages/nimble-components/src/anchor/tests/anchor.spec.ts @@ -2,7 +2,10 @@ import { html } from '@microsoft/fast-element'; import { Anchor, anchorTag } from '..'; import { waitForUpdatesAsync } from '../../testing/async-helpers'; import { fixture, Fixture } from '../../utilities/tests/fixture'; -import { getSpecTypeByNamedList } from '../../utilities/tests/parameterized'; +import { + getSpecTypeByNamedList, + parameterizeNamedList +} from '../../utilities/tests/parameterized'; async function setup(): Promise> { return fixture(html``); @@ -91,4 +94,72 @@ describe('Anchor', () => { }); } }); + + describe('contenteditable behavior', () => { + let innerAnchor: HTMLAnchorElement; + + beforeEach(async () => { + await connect(); + innerAnchor = element.shadowRoot!.querySelector('a')!; + }); + + it('has undefined property value and inner anchor isContentEditable is false by default', () => { + expect(element.contentEditable).toBeUndefined(); + expect(innerAnchor.isContentEditable).toBeFalse(); + }); + + const interestingValues = [ + { name: '', expected: true, skipTag: '' }, + { name: 'true', expected: true, skipTag: '' }, + { name: 'false', expected: false, skipTag: '' }, + { name: 'plaintext-only', expected: true, skipTag: '#SkipFirefox' }, + { name: 'inherit', expected: false, skipTag: '' }, + { name: 'badvalue', expected: false, skipTag: '' } + ] as const; + + parameterizeNamedList(interestingValues, (spec, name, value) => { + spec( + `inner anchor isContentEditable is ${value.expected.toString()} when attribute set to "${name}" ${ + value.skipTag + }`, + async () => { + element.setAttribute('contenteditable', name); + await waitForUpdatesAsync(); + expect(innerAnchor.isContentEditable).toEqual( + value.expected + ); + } + ); + }); + + parameterizeNamedList(interestingValues, (spec, name, value) => { + spec( + `inner anchor isContentEditable is ${value.expected.toString()} when property set to "${name}" ${ + value.skipTag + }`, + async () => { + element.contentEditable = name; + await waitForUpdatesAsync(); + expect(innerAnchor.isContentEditable).toEqual( + value.expected + ); + } + ); + }); + }); + + describe('with contenteditable without value', () => { + async function setupWithContenteditable(): Promise> { + return fixture( + html`` + ); + } + + it('acts like value is "true"', async () => { + ({ element, connect, disconnect } = await setupWithContenteditable()); + await connect(); + const innerAnchor = element.shadowRoot!.querySelector('a')!; + expect(innerAnchor.isContentEditable).toBeTrue(); + }); + }); }); diff --git a/packages/nimble-components/src/anchor/tests/anchor.stories.ts b/packages/nimble-components/src/anchor/tests/anchor.stories.ts index a1dea3bfc2..fae5ba6b63 100644 --- a/packages/nimble-components/src/anchor/tests/anchor.stories.ts +++ b/packages/nimble-components/src/anchor/tests/anchor.stories.ts @@ -15,6 +15,7 @@ interface AnchorArgs { label: string; href: string; underlineHidden: boolean; + contenteditable: string; appearance: keyof typeof AnchorAppearance; } @@ -34,11 +35,13 @@ const metadata: Meta = { - Click on the <${anchorTag} + x.contenteditable}>Click on the <${anchorTag} href=${x => (x.href !== '' ? x.href : null)} ?underline-hidden=${x => x.underlineHidden} + contenteditable=${x => x.contenteditable} appearance=${x => x.appearance} >${x => x.label} to navigate. `), @@ -56,12 +59,19 @@ const metadata: Meta = { control: { type: 'radio' }, description: 'Set to `prominent` to make the anchor appear in a different color than normal text.' + }, + contenteditable: { + options: ['false', 'true'], + control: { type: 'radio' }, + description: + 'Set this to the string "true" (or set the attribute without any value) when the anchor is within an editable region (i.e. element/hierarchy with [contenteditable](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/contenteditable)). Whereas native elements inherit their `contenteditable` value by default, the `nimble-anchor` requires this attribute be explicitly set.' } }, args: { label: 'link', href: 'https://nimble.ni.dev', underlineHidden: false, + contenteditable: 'false', appearance: 'default' } };