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

feat: Implement new Input control component #143

Merged
merged 11 commits into from
Mar 13, 2025
Prev Previous commit
Next Next commit
feat: Implement remaining input functionalities
  • Loading branch information
spuxx-dev committed Mar 13, 2025
commit 2a6d611c2e944a9358c5658a550e22f26da09dda
134 changes: 97 additions & 37 deletions apps/solid/src/routes/components/control/input.route.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Component } from 'solid-js';
import { Component, For } from 'solid-js';
import { Container, Heading, Divider, Input } from '@spuxx/solid';

export const InputRoute: Component = () => {
@@ -7,6 +7,8 @@ export const InputRoute: Component = () => {
input.reportValidity();
}

const variants = ['contained', 'outlined'];

return (
<>
<Container tag="article">
@@ -17,54 +19,112 @@ export const InputRoute: Component = () => {
<Divider color="gradient" />
<Input label="contained (default)" class="m-1" />
<Input label="outlined" variant="outlined" class="m-1" />
<Input label="contained, disabled" class="m-1" disabled />
<Input label="outlined, disabled" variant="outlined" class="m-1" disabled />
</Container>
<Container variant="contained" color="background">
<Heading level={2}>Validation</Heading>
<Divider color="gradient" />
<Input
label="contained, required"
class="m-1"
required
attrs={{ minlength: 3 }}
onInput={validate}
/>
<Input
label="outlined, required"
variant="outlined"
class="m-1"
required
attrs={{ minlength: 3 }}
onInput={validate}
/>
<For each={variants}>
{(variant) => (
<>
<Input
label="required"
class="m-1"
required
attrs={{ minlength: 5 }}
onInput={validate}
variant={variant}
/>
<Input
label="Fruit"
class="m-1"
icon="mdi:fruit-cherries"
variant={variant}
options={[
{
value: 'Apple',
label: 'A healthy apple.',
},
{
value: 'Banana',
label: 'A sweet banana.',
},
{
value: 'Melon',
label: 'A juicy melon.',
},
]}
forceOption
attrs={{ type: 'text' }}
onInput={validate}
/>
</>
)}
</For>
</Container>
<Container variant="contained" color="background">
<Heading level={2}>Sizes</Heading>
<Divider color="gradient" />
<Input label="auto (default)" class="m-1" />
<Input label="small" class="m-1" size="small" />
<Input label="medium" class="m-1" size="medium" />
<Input label="large" class="m-1" size="large" />
<Input label="full" class="m-1" size="full" />
<For each={variants}>
{(variant) => (
<>
<Input label="auto (default)" class="m-1" variant={variant} />
<Input label="small" class="m-1" size="small" variant={variant} />
<Input label="medium" class="m-1" size="medium" variant={variant} />
<Input label="large" class="m-1" size="large" variant={variant} />
<Input label="full" class="m-1" size="full" variant={variant} />
</>
)}
</For>
</Container>
<Container variant="contained" color="background">
<Heading level={2}>With Icon</Heading>
<Divider color="gradient" />
<Input label="Username" class="m-1" icon="mdi:account" attrs={{ type: 'text' }} />
<Input label="Password" class="m-1" icon="mdi:lock" attrs={{ type: 'password' }} />
<Input
label="Username"
class="m-1"
variant="outlined"
icon="mdi:account"
attrs={{ type: 'text' }}
/>
<Input
label="Password"
class="m-1"
variant="outlined"
icon="mdi:lock"
attrs={{ type: 'password' }}
/>
<For each={variants}>
{(variant) => (
<>
<Input
label="Username"
class="m-1"
icon="mdi:account"
attrs={{ type: 'text' }}
variant={variant}
/>
<Input
label="Password"
class="m-1"
icon="mdi:lock"
attrs={{ type: 'password' }}
variant={variant}
/>
</>
)}
</For>
</Container>
<Container variant="contained" color="background">
<Heading level={2}>Input Types</Heading>
<Divider color="gradient" />
<For each={variants}>
{(variant) => (
<>
<Input
label="Username"
class="m-1"
icon="mdi:account"
attrs={{ type: 'text' }}
variant={variant}
/>
<Input
label="Password"
class="m-1"
icon="mdi:lock"
attrs={{ type: 'password' }}
variant={variant}
/>
</>
)}
</For>
</Container>
</Container>
</>
12 changes: 6 additions & 6 deletions packages/browser-utils/src/styles/components/control/input.css
Original file line number Diff line number Diff line change
@@ -51,18 +51,18 @@
}

.spx-input[spx-size='small'] {
min-width: var(--spx-control-width-small);
max-width: min(var(--spx-control-width-small), 100%);
width: var(--spx-control-width-small);
max-width: 100%;
}

.spx-input[spx-size='medium'] {
min-width: var(--spx-control-width-medium);
max-width: min(var(--spx-control-width-medium), 100%);
width: var(--spx-control-width-medium);
max-width: 100%;
}

.spx-input[spx-size='large'] {
min-width: var(--spx-control-width-large);
max-width: min(var(--spx-control-width-large), 100%);
width: var(--spx-control-width-large);
max-width: 100%;
}

/* Common */
109 changes: 109 additions & 0 deletions packages/solid/src/components/control/input/input.component.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { describe, expect, it, vi } from 'vitest';
import { Input } from './input.component';
import { fireEvent, render } from '@solidjs/testing-library';

describe('Input', () => {
it('should render with default values', () => {
const { container } = render(() => <Input label="Username" />);
const inputContainer = container.querySelector('.spx-input');
expect(inputContainer).toBeInTheDocument();
expect(inputContainer).toHaveAttribute('spx-variant', 'contained');
expect(inputContainer).not.toHaveAttribute('spx-size');
expect(inputContainer).toHaveClass('spx', 'spx-input');
const input = inputContainer?.querySelector('input');
expect(input).toBeInTheDocument();
expect(input).toHaveAttribute('type', 'text');
expect(input).toHaveAttribute('placeholder', ' ');
expect(input).not.toHaveAttribute('aria-placeholder');
expect(input).not.toHaveAttribute('required');
expect(input).not.toHaveAttribute('list');
expect(input).not.toHaveAttribute('pattern');
expect(input).not.toHaveAttribute('disabled');
const label = inputContainer?.querySelector('label');
expect(label).toBeInTheDocument();
expect(label!.querySelector('iconify-icon')).not.toBeInTheDocument();
expect(label).toHaveTextContent('Username');
expect(label).toHaveAttribute('for', input!.id);
});

it('should render with custom values', () => {
const { container } = render(() => (
<Input
label="Username"
variant="outlined"
size="small"
class="my-class"
style={{ color: 'rgb(255, 0, 0)' }}
type="email"
disabled
required
/>
));
const inputContainer = container.querySelector('.spx-input');
expect(inputContainer).toBeInTheDocument();
expect(inputContainer).toHaveAttribute('spx-variant', 'outlined');
expect(inputContainer).toHaveAttribute('spx-size', 'small');
expect(inputContainer).toHaveClass('spx', 'spx-input', 'my-class');
expect(inputContainer).toHaveStyle({ color: 'rgb(255, 0, 0)' });
const input = inputContainer?.querySelector('input');
expect(input).toBeInTheDocument();
expect(input).toHaveAttribute('type', 'email');
expect(input).toHaveAttribute('required');
expect(input).toHaveAttribute('disabled');
});

it('should render with an icon', () => {
const { container } = render(() => <Input label="Username" icon="mdi:account" />);
const inputContainer = container.querySelector('.spx-input');
expect(inputContainer).toBeInTheDocument();
const label = inputContainer?.querySelector('label');
expect(label).toBeInTheDocument();
expect(label!.querySelector('iconify-icon')).toBeInTheDocument();
expect(label!.querySelector('iconify-icon')).toHaveAttribute('icon', 'mdi:account');
});

it('should apply additional attributes to the input', () => {
const { container } = render(() => <Input label="Username" attrs={{ min: 5 }} />);
const input = container.querySelector('input');
expect(input).toHaveAttribute('min', '5');
});

it('should render with a list of options', () => {
const options = [
{ value: 'one', label: 'One' },
{ value: 'two', label: 'Two' },
];
const { container } = render(() => <Input label="Username" options={options} />);
const input = container.querySelector('input');
expect(input).toHaveAttribute('list');
const datalist = container.querySelector('datalist');
expect(datalist).toBeInTheDocument();
expect(datalist?.querySelectorAll('option')).toHaveLength(2);
});

it("should add a pattern containing all options if 'forceOption' is true", () => {
const options = [
{ value: 'one', label: 'One' },
{ value: 'two', label: 'Two' },
];
const { container } = render(() => <Input label="Username" options={options} forceOption />);
const input = container.querySelector('input');
expect(input).toHaveAttribute('pattern', 'one|two');
});

it("should call the 'onChange' callback", () => {
const onChange = vi.fn();
const { container } = render(() => <Input label="Username" onChange={onChange} />);
const input = container.querySelector('input');
fireEvent.change(input!, { target: { value: 'spuxx' } });
expect(onChange).toHaveBeenCalledTimes(1);
});

it("should call the 'onInput' callback", () => {
const onInput = vi.fn();
const { container } = render(() => <Input label="Username" onInput={onInput} />);
const input = container.querySelector('input');
fireEvent.input(input!, { key: 's' });
expect(onInput).toHaveBeenCalledTimes(1);
});
});
39 changes: 31 additions & 8 deletions packages/solid/src/components/control/input/input.component.tsx
Original file line number Diff line number Diff line change
@@ -1,42 +1,65 @@
import { Component } from 'solid-js';
import { Component, For, JSX } from 'solid-js';
import { InputProps } from './input.types';
import { attributes, classNames } from '@src/main';
import { Icon } from '@iconify-icon/solid';
import { InputType } from '@spuxx/browser-utils';

export const Input: Component<InputProps> = (props) => {
const { variant = 'contained', size, required } = props;
const {
type = InputType.text,
variant = 'contained',
size,
required,
options,
forceOption,
} = props;
const id = props.attrs?.id ?? crypto.randomUUID();
const listId = `${id}-options`;
const pattern =
options && forceOption ? options.map((o) => o.value).join('|') : props.attrs?.pattern;

const handleChange = (event: Event) => {
const value = (event.target as HTMLInputElement).value;
if (props.onChange) props.onChange(value, event);
const handleChange: JSX.EventHandler<HTMLInputElement, Event> = (event) => {
const input = event.currentTarget as HTMLInputElement;
if (props.onChange) props.onChange(input.value, event);
};

const handleInput = (event: Event) => {
const value = (event.target as HTMLInputElement).value;
if (props.onInput) props.onInput(value, event);
const handleInput: JSX.EventHandler<HTMLInputElement, Event> = (event) => {
const input = event.currentTarget as HTMLInputElement;
if (props.onInput) props.onInput(input.value, event);
};

return (
<div
{...classNames('spx-input', props.class)}
style={props.style}
spx-variant={variant}
spx-size={size || undefined}
>
<input
{...attributes(props)}
id={id}
type={type}
list={options ? listId : undefined}
placeholder=" "
aria-placeholder={undefined}
required={required || undefined}
onchange={handleChange}
oninput={handleInput}
pattern={pattern}
disabled={props.disabled || undefined}
/>
<label for={id}>
{props.icon && <Icon icon={props.icon} />}
{props.label}
{required && ' *'}
</label>
{options && (
<datalist id={listId}>
<For each={options}>
{(option) => <option value={option.value} label={option.label} />}
</For>
</datalist>
)}
</div>
);
};
Loading
Oops, something went wrong.
Loading
Oops, something went wrong.