Skip to content

Commit

Permalink
feat: add option to clear content of input
Browse files Browse the repository at this point in the history
  • Loading branch information
josemarluedke committed Apr 5, 2024
1 parent 5df5c63 commit a1ab773
Show file tree
Hide file tree
Showing 6 changed files with 227 additions and 47 deletions.
123 changes: 106 additions & 17 deletions packages/forms/src/components/input.gts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { on } from '@ember/modifier';
import {
Expand All @@ -8,6 +9,8 @@ import {
type SlotsToClasses
} from '@frontile/theme';
import { FormControl, type FormControlSharedArgs } from './form-control';
import { triggerFormInputEvent } from '../utils';
import { ref } from '@frontile/utilities';

interface Args extends FormControlSharedArgs {
type?: string;
Expand All @@ -16,11 +19,26 @@ interface Args extends FormControlSharedArgs {
size?: InputVariants['size'];
classes?: SlotsToClasses<InputSlots>;

// Whether to include a clear button
isClearable?: boolean;

/* Controls pointer-events property of startContent.
* If you want to pass the click event to the input, set it to `none`.
* @defaultValue 'auto'
*/
startContentPointerEvents?: 'none' | 'auto';

/* Controls pointer-events property of endContent.
* If you want to pass the click event to the input, set it to `none`.
* @defaultValue 'auto'
*/
endContentPointerEvents?: 'none' | 'auto';

// Callback when oninput is triggered
onInput?: (value: string, event: InputEvent) => void;
onInput?: (value: string, event?: InputEvent) => void;

// Callback when onchange is triggered
onChange?: (value: string, event: InputEvent) => void;
onChange?: (value: string, event?: InputEvent) => void;
}

interface InputSignature {
Expand All @@ -32,7 +50,26 @@ interface InputSignature {
Element: HTMLInputElement;
}

function or(arg1: unknown, arg2: unknown): boolean {
return !!(arg1 || arg2);
}

class Input extends Component<InputSignature> {
@tracked uncontrolledValue: string = '';

inputRef = ref<HTMLInputElement>();

get isControlled() {
return (
typeof this.args.onChange === 'function' ||
typeof this.args.onInput === 'function'
);
}

get value(): string | undefined {
return this.isControlled ? this.args.value : this.uncontrolledValue;
}

get type(): string {
if (typeof this.args.type === 'string') {
return this.args.type;
Expand All @@ -41,21 +78,46 @@ class Input extends Component<InputSignature> {
}

@action handleOnInput(event: Event): void {
if (typeof this.args.onInput === 'function') {
this.args.onInput(
(event.target as HTMLInputElement).value,
event as InputEvent
);
const value = (event.target as HTMLInputElement).value;

if (this.isControlled) {
this.args.onInput?.(value, event as InputEvent);
} else {
this.uncontrolledValue = value;
}
}

@action handleOnChange(event: Event): void {
if (typeof this.args.onChange === 'function') {
this.args.onChange(
(event.target as HTMLInputElement).value,
event as InputEvent
);
const value = (event.target as HTMLInputElement).value;

if (this.isControlled) {
this.args.onChange?.(value, event as InputEvent);
} else {
this.uncontrolledValue = value;
}
}

@action clearValue(): void {
if (this.isControlled) {
this.args.onChange?.('');
this.args.onInput?.('');
} else {
this.uncontrolledValue = '';
}

this.inputRef.element?.focus();
triggerFormInputEvent(this.inputRef.element);
}

get isClearable(): boolean {
if (
this.args.isClearable === true &&
this.value !== '' &&
typeof this.value !== 'undefined'
) {
return true;
}
return false;
}

get classes() {
Expand All @@ -78,30 +140,57 @@ class Input extends Component<InputSignature> {
>
<div class={{this.classes.innerContainer class=@classes.innerContainer}}>
{{#if (has-block "startContent")}}
<div class={{this.classes.startContent class=@classes.startContent}}>
<div
data-test-id="input-start-content"
class={{this.classes.startContent
class=@classes.startContent
startContentPointerEvents=(if
@startContentPointerEvents @startContentPointerEvents "auto"
)
}}
>
{{yield to="startContent"}}
</div>
{{/if}}
<input
{{this.inputRef.setup}}
{{on "input" this.handleOnInput}}
{{on "change" this.handleOnChange}}
id={{c.id}}
name={{@name}}
value={{@value}}
value={{this.value}}
type={{this.type}}
class={{this.classes.input
class=@classes.input
hasStartContent=(has-block "startContent")
hasEndContent=(has-block "endContent")
hasEndContent=(or (has-block "endContent") this.isClearable)
}}
data-component="input"
aria-invalid={{if c.isInvalid "true"}}
aria-describedby={{c.describedBy @description c.isInvalid}}
...attributes
/>
{{#if (has-block "endContent")}}
<div class={{this.classes.endContent class=@classes.endContent}}>
{{#if (or (has-block "endContent") this.isClearable)}}
<div
data-test-id="input-end-content"
class={{this.classes.endContent
class=@classes.endContent
endContentPointerEvents=(if
@endContentPointerEvents @endContentPointerEvents "auto"
)
}}
>
{{yield to="endContent"}}

{{#if this.isClearable}}
<button
data-test-id="input-clear-button"
type="button"
{{on "click" this.clearValue}}
>
x
</button>
{{/if}}
</div>
{{/if}}
</div>
Expand Down
26 changes: 2 additions & 24 deletions packages/forms/src/components/select.gts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import Component from '@glimmer/component';
import type { TOC } from '@ember/component/template-only';
import { tracked } from '@glimmer/tracking';
import { modifier } from 'ember-modifier';
import { buildWaiter } from '@ember/test-waiters';
import { NativeSelect, type ListItem } from './native-select';
import { Listbox, type ListboxSignature } from '@frontile/collections';
import {
Expand All @@ -18,23 +17,7 @@ import {
type ContentSignature
} from '@frontile/overlays';
import { FormControl, type FormControlSharedArgs } from './form-control';

function triggerFormInputEvent(element: HTMLElement | null): void {
if (!element) return;

let parent = element.parentElement;
while (parent) {
if (parent.tagName === 'FORM') {
(parent as HTMLFormElement).dispatchEvent(
new Event('input', { bubbles: true })
);
break;
}
parent = parent.parentElement;
}
}

const waiter = buildWaiter('@frontile/forms:select');
import { triggerFormInputEvent } from '../utils';

interface SelectArgs<T>
extends Pick<
Expand Down Expand Up @@ -131,18 +114,13 @@ class Select<T = unknown> extends Component<SelectSignature<T>> {
}

onSelectionChange = (keys: string[]) => {
const waiterToken = waiter.beginAsync();

if (typeof this.args.onSelectionChange === 'function') {
this.args.onSelectionChange(keys);
} else {
this._selectedKeys = keys;
}

requestAnimationFrame(() => {
triggerFormInputEvent(this.el);
waiter.endAsync(waiterToken);
});
triggerFormInputEvent(this.el);
};

onOpenChange = (isOpen: boolean) => {
Expand Down
24 changes: 24 additions & 0 deletions packages/forms/src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { buildWaiter } from '@ember/test-waiters';
const waiter = buildWaiter('@frontile/forms:triggerFormInputEvent)');

function triggerFormInputEvent(element?: HTMLElement | null): void {
if (!element) return;
const waiterToken = waiter.beginAsync();

requestAnimationFrame(() => {
let parent = element.parentElement;
while (parent) {
if (parent.tagName === 'FORM') {
(parent as HTMLFormElement).dispatchEvent(
new Event('input', { bubbles: true })
);
break;
}
parent = parent.parentElement;
}

waiter.endAsync(waiterToken);
});
}

export { triggerFormInputEvent };
14 changes: 10 additions & 4 deletions packages/theme/src/components/forms/forms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,8 @@ const input = tv({
slots: {
base: '',
innerContainer: 'relative flex',
startContent:
'absolute inset-y-0 left-0 flex items-center pointer-events-none',
endContent:
'absolute inset-y-0 right-0 flex items-center pointer-events-none',
startContent: 'absolute inset-y-0 left-0 flex items-center',
endContent: 'absolute inset-y-0 right-0 flex items-center',
input: [
'appearance-none',
'flex-1',
Expand Down Expand Up @@ -95,6 +93,14 @@ const input = tv({
},
hasEndContent: {
true: { input: 'pe-10' }
},
startContentPointerEvents: {
auto: { startContent: 'pointer-events-auto' },
none: { startContent: 'pointer-events-none' }
},
endContentPointerEvents: {
auto: { endContent: 'pointer-events-auto' },
none: { endContent: 'pointer-events-none' }
}
},
defaultVariants: {
Expand Down
8 changes: 7 additions & 1 deletion test-app/app/components/forms/form-example.gts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,12 @@ export default class FormExample extends Component<FormExampleArgs> {

<template>
<Form @onChange={{this.onChange}} class="flex flex-1 flex-col gap-4">
<Input @name="field-0" @label="field 0">
<Input
@name="field-0"
@label="field 0"
@endContentPointerEvents="none"
@startContentPointerEvents="none"
>
<:startContent>
@
</:startContent>
Expand All @@ -60,6 +65,7 @@ export default class FormExample extends Component<FormExampleArgs> {
@label={{MyCustomLabel}}
@description="Cool field"
@isRequired={{true}}
@isClearable={{true}}
/>

<Radio
Expand Down
Loading

0 comments on commit a1ab773

Please sign in to comment.