Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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,95 @@
import { noChange, nothing } from 'lit';
import type { ElementPart } from 'lit';
import {
directive,
Directive,
type DirectiveParameters,
PartInfo,
PartType,
} from 'lit/directive.js';

/**
* The directive applies or removes attributes to disable known and commonly used password managers on the given element.
*
* The `attributes` field is `protected` and `readonly` to allow subclasses to extend the list of
Comment thread
iOvergaard marked this conversation as resolved.
* managed attributes without replacing the base implementation. Each entry must have a `name` and `value`
* that together signal to a password manager that it should ignore the element.
*
* To add support for additional password managers, subclass and redeclare `attributes` with the
* full list (base entries + any new ones):
* @example
* ```ts
* class MyDirective extends UUIDisablePasswordManagersDirective {
* protected override readonly attributes = [
* { name: 'data-1p-ignore', value: '' },
* { name: 'data-bwignore', value: '' },
* { name: 'data-form-type', value: 'other' },
* { name: 'data-lpignore', value: 'true' },
* { name: 'data-custom-ignore', value: 'true' }, // additional manager
* ];
* }
* export const myDisablePasswordManagers = directive(MyDirective);
* ```
*/
export class UUIDisablePasswordManagersDirective extends Directive {
/**
* The list of attributes to apply or remove on the target element.
* Override this in a subclass to extend support for additional password managers.
*/
protected readonly attributes: ReadonlyArray<{ name: string; value: string }> =

Check failure on line 39 in packages/uui-base/lib/directives/disable-password-managers.directive.ts

View workflow job for this annotation

GitHub Actions / test

Replace `·name:·string;·value:·string·}>·=⏎···` with `⏎····name:·string;⏎····value:·string;⏎··}>·=`
Object.freeze([
{ name: 'data-1p-ignore', value: '' }, // 1Password

Check failure on line 41 in packages/uui-base/lib/directives/disable-password-managers.directive.ts

View workflow job for this annotation

GitHub Actions / test

Delete `··`
{ name: 'data-bwignore', value: '' }, // Bitwarden

Check failure on line 42 in packages/uui-base/lib/directives/disable-password-managers.directive.ts

View workflow job for this annotation

GitHub Actions / test

Delete `··`
{ name: 'data-form-type', value: 'other' }, // Dashlane

Check failure on line 43 in packages/uui-base/lib/directives/disable-password-managers.directive.ts

View workflow job for this annotation

GitHub Actions / test

Delete `··`
{ name: 'data-lpignore', value: 'true' }, // LastPass

Check failure on line 44 in packages/uui-base/lib/directives/disable-password-managers.directive.ts

View workflow job for this annotation

GitHub Actions / test

Delete `··`
]);

Check failure on line 45 in packages/uui-base/lib/directives/disable-password-managers.directive.ts

View workflow job for this annotation

GitHub Actions / test

Delete `··`

constructor(partInfo: PartInfo) {
super(partInfo);
if (partInfo.type !== PartType.ELEMENT) {
throw new Error(
'The `uuiDisablePasswordManagers` directive can only be used in element parts',
Comment thread
iOvergaard marked this conversation as resolved.
);
}
}

/**
* The directive does not render any content.
* @returns `nothing` to indicate that the directive does not render any content.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
override render(_enabled: boolean) {
return nothing;
}

/**
* Applies or removes password manager ignore attributes on the element depending on `enabled`.
* Calling with `false` removes all managed attributes so toggling the property is fully reversible.
* @param part - The element part where the directive is applied.
* @param enabled - When `true`, sets the ignore attributes; when `false`, removes them.
* @returns `noChange` to indicate that no further updates are needed for this part.
*/
override update(part: ElementPart, [enabled]: DirectiveParameters<this>) {
this.attributes.forEach(attr => {
if (enabled) {
part.element.setAttribute(attr.name, attr.value);
} else {
part.element.removeAttribute(attr.name);
}
});
return noChange;
}
}

/**
* A Lit directive which applies or removes attributes to disable known and commonly used password managers on an element.
* Currently supports 1Password, Bitwarden, Dashlane, and LastPass.
*
* Pass `true` to suppress password manager behaviour; pass `false` (or toggle back) to restore it.
* The directive is fully reversible: switching from `true` to `false` removes all managed attributes.
*
* @example html`<input ${uuiDisablePasswordManagers(this.disablePasswordManagers)} />`
*/
export const uuiDisablePasswordManagers = directive(
UUIDisablePasswordManagersDirective,
);
68 changes: 68 additions & 0 deletions packages/uui-base/lib/directives/disable-password-managers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { html, fixture, expect } from '@open-wc/testing';
import { render } from 'lit';
import { uuiDisablePasswordManagers } from './disable-password-managers.directive';

const PASSWORD_MANAGER_ATTRS = [
['data-1p-ignore', ''],
['data-bwignore', ''],
['data-form-type', 'other'],
['data-lpignore', 'true'],
] as const;

function expectAttrsPresent(element: Element) {
PASSWORD_MANAGER_ATTRS.forEach(([name, value]) => {
expect(element.getAttribute(name)).to.equal(value);
});
}

function expectAttrsAbsent(element: Element) {
Comment thread
iOvergaard marked this conversation as resolved.
PASSWORD_MANAGER_ATTRS.forEach(([name]) => {
expect(element.hasAttribute(name)).to.be.false;
});
}

Comment thread
iOvergaard marked this conversation as resolved.
describe('UUIDisablePasswordManagersDirective', () => {
it('applies the correct attributes when enabled on a div', async () => {
const element = await fixture(html`
<div ${uuiDisablePasswordManagers(true)}>Div test element</div>
`);
expectAttrsPresent(element);
});

it('applies the correct attributes when enabled on an input', async () => {
const element = await fixture(html`
<input
name="password"
autocomplete="current-password"
${uuiDisablePasswordManagers(true)} />
`);
expectAttrsPresent(element);
});

it('does not apply attributes when disabled', async () => {
const element = await fixture(html`
<input ${uuiDisablePasswordManagers(false)} />
`);
expectAttrsAbsent(element);
});

it('removes attributes when toggled from true to false', async () => {
// Must use the same template function so Lit reuses the same directive
// instance across renders. Two separate html`` literals have different
// strings-array references and would cause Lit to replace the DOM entirely.
const template = (enabled: boolean) =>
html`<input ${uuiDisablePasswordManagers(enabled)} />`;

const container = document.createElement('div');
document.body.appendChild(container);

render(template(true), container);
const input = container.querySelector('input')!;
expectAttrsPresent(input);

render(template(false), container);
expectAttrsAbsent(input);

container.remove();
});
});
1 change: 1 addition & 0 deletions packages/uui-base/lib/directives/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './disable-password-managers.directive.js';
Comment thread
iOvergaard marked this conversation as resolved.
1 change: 1 addition & 0 deletions packages/uui-base/lib/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './animations';
export * from './directives';
export * from './events';
export * from './mixins';
export * from './registration';
Expand Down
1 change: 1 addition & 0 deletions packages/uui-base/rollup.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export default UUIProdConfig({
entryPoints: [
'index',
'animations/index',
'directives/index',
'events/index',
'mixins/index',
'types/index',
Expand Down
10 changes: 10 additions & 0 deletions packages/uui-combobox/lib/uui-combobox.element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,15 @@ export class UUIComboboxElement extends UUIFormControlMixin(LitElement, '') {
@property()
placeholder = '';

/**
* Disables password managers from interacting with the input.
* @type {boolean}
* @attr disable-password-managers
* @default false
*/
@property({ type: Boolean, attribute: 'disable-password-managers' })
disablePasswordManagers = false;

@query('#combobox-input')
private _input!: HTMLInputElement;

Expand Down Expand Up @@ -347,6 +356,7 @@ export class UUIComboboxElement extends UUIFormControlMixin(LitElement, '') {
autocomplete="off"
.disabled=${this.disabled}
.readonly=${this.readonly}
.disablePasswordManagers=${this.disablePasswordManagers}
Comment thread
iOvergaard marked this conversation as resolved.
popovertarget="combobox-popover"
@click=${this.#onToggle}
@input=${this.#onInput}
Expand Down
35 changes: 35 additions & 0 deletions packages/uui-combobox/lib/uui-combobox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,41 @@ describe('UUIComboboxElement', () => {
it('has a disabled property', () => {
expect(element).to.have.property('disabled');
});
it('has a disablePasswordManagers property', () => {
expect(element).to.have.property('disablePasswordManagers');
});
});

describe('disablePasswordManagers', () => {
let innerInput: HTMLInputElement;

beforeEach(() => {
const uuiInput = element.shadowRoot!.querySelector('uui-input')!;
innerInput = uuiInput.shadowRoot!.querySelector('input')!;
});

it('does not apply password manager attributes by default', () => {
expect(innerInput.hasAttribute('data-1p-ignore')).to.be.false;
expect(innerInput.hasAttribute('data-lpignore')).to.be.false;
});

it('delegates disablePasswordManagers to the inner uui-input', async () => {
element.disablePasswordManagers = true;
await elementUpdated(element);
expect(innerInput.getAttribute('data-1p-ignore')).to.equal('');
expect(innerInput.getAttribute('data-bwignore')).to.equal('');
expect(innerInput.getAttribute('data-form-type')).to.equal('other');
expect(innerInput.getAttribute('data-lpignore')).to.equal('true');
});

it('removes password manager attributes when toggled back to false', async () => {
element.disablePasswordManagers = true;
await elementUpdated(element);
element.disablePasswordManagers = false;
await elementUpdated(element);
expect(innerInput.hasAttribute('data-1p-ignore')).to.be.false;
expect(innerInput.hasAttribute('data-lpignore')).to.be.false;
});
});

describe('template', () => {
Expand Down
13 changes: 12 additions & 1 deletion packages/uui-input/lib/uui-input.element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
LabelMixin,
} from '@umbraco-ui/uui-base/lib/mixins';
import { defineElement } from '@umbraco-ui/uui-base/lib/registration';
import { uuiDisablePasswordManagers } from '@umbraco-ui/uui-base/lib/directives';
import { css, html, LitElement } from 'lit';
import { property, query } from 'lit/decorators.js';
import { ifDefined } from 'lit/directives/if-defined.js';
Expand Down Expand Up @@ -222,6 +223,15 @@ export class UUIInputElement extends UUIFormControlMixin(
@property({ type: Number, reflect: false, attribute: 'tabindex' })
tabIndex: number = 0;

/**
* Disables password managers from interacting with the input.
* @type {boolean}
* @attr disable-password-managers
* @default false
*/
@property({ type: Boolean, attribute: 'disable-password-managers' })
disablePasswordManagers = false;

@query('#input')
_input!: HTMLInputElement;

Expand Down Expand Up @@ -365,7 +375,8 @@ export class UUIInputElement extends UUIFormControlMixin(
?readonly=${this.readonly}
tabindex=${ifDefined(this.tabIndex)}
@input=${this.onInput}
@change=${this.onChange} />`;
@change=${this.onChange}
${uuiDisablePasswordManagers(this.disablePasswordManagers)} />`;
}

private renderAutoWidthBackground() {
Expand Down
8 changes: 8 additions & 0 deletions packages/uui-input/lib/uui-input.story.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,3 +246,11 @@ export const AutoWidth: Story = {
placeholder: 'Start typing...',
},
};

export const DisablePasswordManagers: Story = {
args: {
type: 'password',
disablePasswordManagers: true,
placeholder: 'Disable password managers',
},
};
30 changes: 30 additions & 0 deletions packages/uui-input/lib/uui-input.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ describe('UuiInputElement', () => {
it('has a autoWidth property', () => {
expect(element).to.have.property('autoWidth');
});
it('has a disablePasswordManagers property', () => {
expect(element).to.have.property('disablePasswordManagers');
});

it('disable property set input to disabled', async () => {
element.disabled = true;
Expand Down Expand Up @@ -159,6 +162,33 @@ describe('UuiInputElement', () => {
expect(element.value).to.equal('test value');
});

it('does not have password manager attributes by default', () => {
expect(input.hasAttribute('data-1p-ignore')).to.be.false;
expect(input.hasAttribute('data-bwignore')).to.be.false;
expect(input.hasAttribute('data-form-type')).to.be.false;
expect(input.hasAttribute('data-lpignore')).to.be.false;
});

it('applies password manager ignore attributes when disablePasswordManagers is true', async () => {
Comment thread
iOvergaard marked this conversation as resolved.
element.disablePasswordManagers = true;
await elementUpdated(element);
expect(input.getAttribute('data-1p-ignore')).to.equal('');
expect(input.getAttribute('data-bwignore')).to.equal('');
expect(input.getAttribute('data-form-type')).to.equal('other');
expect(input.getAttribute('data-lpignore')).to.equal('true');
});

it('removes password manager ignore attributes when disablePasswordManagers is toggled back to false', async () => {
element.disablePasswordManagers = true;
await elementUpdated(element);
element.disablePasswordManagers = false;
await elementUpdated(element);
expect(input.hasAttribute('data-1p-ignore')).to.be.false;
expect(input.hasAttribute('data-bwignore')).to.be.false;
expect(input.hasAttribute('data-form-type')).to.be.false;
expect(input.hasAttribute('data-lpignore')).to.be.false;
});

describe('text overflow', () => {
it('has text-overflow ellipsis applied to input element', () => {
const computedStyle = window.getComputedStyle(input);
Expand Down
6 changes: 3 additions & 3 deletions packages/uui-textarea/lib/uui-textarea.element.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { LitElement, html, css } from 'lit';
import { defineElement } from '@umbraco-ui/uui-base/lib/registration';
import { property, query } from 'lit/decorators.js';
import { UUITextareaEvent } from './UUITextareaEvent';
import { UUIFormControlMixin } from '@umbraco-ui/uui-base/lib/mixins';
import { ifDefined } from 'lit/directives/if-defined.js';
import { defineElement } from '@umbraco-ui/uui-base/lib/registration';
import { UUIFormControlMixin } from '@umbraco-ui/uui-base/lib/mixins';
import { UUITextareaEvent } from './UUITextareaEvent';

/**
* @element uui-textarea
Expand Down
Loading