Skip to content

Commit

Permalink
SELF-302: Add KeyValueInput Component (#514)
Browse files Browse the repository at this point in the history
* feat: add key value input component

* feat: add key value input props
  • Loading branch information
NateWaldschmidt committed Jun 13, 2024
1 parent 0e52e93 commit 48feccd
Show file tree
Hide file tree
Showing 10 changed files with 209 additions and 3 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# CHANGELOG

## v2.0.71

- Add `KeyValueInput` component

## v2.0.70

- Add `EnvelopeSearch` and `ImageSearch` icons, and `CUSTOM_ENVELOPE` and `HTML_TEMPLATE` semantic icon entries.
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@lob/ui-components",
"version": "2.0.70",
"version": "2.0.71",
"engines": {
"node": ">=20.2.0",
"npm": ">=10.2.0"
Expand Down
6 changes: 6 additions & 0 deletions src/assets/styles/main.scss
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,12 @@
@apply cursor-not-allowed;
}
}

// TODO Move to `@layer components` when that works
.uic-label {
@apply type-small-500 text-gray-800;
@apply mb-1;
}
}

// This is a hack for the modal styles flashing.
Expand Down
10 changes: 10 additions & 0 deletions src/components/KeyValueInput/KeyValueInput.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { ArgTypes, Canvas, PRIMARY_STORY } from '@storybook/addon-docs';
import { Primary } from './KeyValueInput.stories';

# KeyValueInput

<Canvas of={Primary} />

## Props

<ArgTypes story={PRIMARY_STORY} />
30 changes: 30 additions & 0 deletions src/components/KeyValueInput/KeyValueInput.stories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { Meta, StoryObj } from '@storybook/vue3';
import mdx from './KeyValueInput.mdx';
// @ts-ignore No types from Vue file
import KeyValueInput from './KeyValueInput.vue';

const meta: Meta<typeof KeyValueInput> = {
title: 'Components/KeyValueInput',
component: KeyValueInput,
parameters: {
docs: {
page: mdx
}
}
};

export default meta;

const keyValueModel: [string, string][] = [];

export const Primary: StoryObj<typeof KeyValueInput> = {
args: {
label: 'Key Value Input'
},
render: (args) => ({
components: { KeyValueInput },
data: () => ({ keyValueModel }),
setup: () => ({ args }),
template: `<KeyValueInput v-bind="args" v-model="keyValueModel" style="width: 30rem;" />`
})
};
135 changes: 135 additions & 0 deletions src/components/KeyValueInput/KeyValueInput.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
<template>
<div data-testid="uic-key-value-input">
<fieldset class="w-full">
<legend class="uic-label">{{ label }}</legend>
<ul class="flex flex-col gap-4 w-full">
<li
v-for="([key, value], index) in modelValue"
:key="`key-value-${index}`"
class="grid gap-6 items-start"
style="grid-template-columns: 1fr 1fr min-content"
>
<TextInput
:id="`key-${index}`"
:data-testid="`uic-key-value-input-key-${index}`"
:disabled
:error="Boolean(keyErrors?.[index])"
:helper-text="keyErrors?.[index]"
:label="keyLabel"
:model-value="key"
sr-only-label
@update:modelValue="handleKeyChange(index, $event)"
@blur="$emit('keyBlur', index)"
@focus="$emit('keyFocus', index)"
@input="$emit('keyInput', index)"
/>
<TextInput
:id="`value-${index}`"
:data-testid="`uic-key-value-input-value-${index}`"
:disabled
:error="Boolean(valueErrors?.[index])"
:helper-text="valueErrors?.[index]"
:label="valueLabel"
:model-value="value"
sr-only-label
@update:modelValue="handleValueChange(index, $event)"
@blur="$emit('valueBlur', index)"
@focus="$emit('valueFocus', index)"
@input="$emit('valueInput', index)"
/>
<IconButton
:data-testid="`uic-key-value-input-delete-${index}`"
:disabled
icon="Delete"
type="button"
color="error"
variant="outlined"
@click="handleDelete(index)"
/>
</li>
</ul>
</fieldset>
<Button
class="mt-2"
data-testid="uic-key-value-add-button"
size="medium"
type="button"
variant="ghost"
@click="handleAddField"
>
<Icon icon="Plus" class="mr-1" size="sm" />
{{ addLabel }}
</Button>
</div>
</template>
<script setup lang="ts">
import Button from '@/components/Button/Button.vue';
import { Icon } from '@/components/Icon';
import { IconButton } from '@/components/IconButton';
import TextInput from '@/components/TextInput/TextInput.vue';
const props = withDefaults(
defineProps<{
addLabel?: string;
disabled?: boolean;
/** Errors correspond with the field's index. */
keyErrors?: Record<number, string>;
keyLabel?: string;
label: string;
/**
* The `modelValue` is typed this way to allow invalid duplicated keys to be
* shown with errors. To convert this to an object use
* `Object.fromEntries(...)`
*/
modelValue: [string, string][];
/** Errors correspond with the field's index. */
valueErrors?: Record<number, string>;
valueLabel?: string;
}>(),
{
addLabel: 'Add field',
disabled: false,
keyErrors: undefined,
keyLabel: 'Key',
valueErrors: undefined,
valueLabel: 'Value'
}
);
const emit = defineEmits<{
(e: 'update:modelValue', value: [string, string][]): void; // eslint-disable-line no-unused-vars
(e: 'keyFocus', index: number): void; // eslint-disable-line no-unused-vars
(e: 'valueFocus', index: number): void; // eslint-disable-line no-unused-vars
(e: 'keyBlur', index: number): void; // eslint-disable-line no-unused-vars
(e: 'valueBlur', index: number): void; // eslint-disable-line no-unused-vars
(e: 'keyInput', index: number): void; // eslint-disable-line no-unused-vars
(e: 'valueInput', index: number): void; // eslint-disable-line no-unused-vars
}>();
const handleKeyChange = (index: number, newKey: string) => {
const newModelValue = [...props.modelValue];
newModelValue[index][0] = newKey;
emit('update:modelValue', newModelValue);
};
const handleValueChange = (index: number, newValue: string) => {
const newModelValue = [...props.modelValue];
newModelValue[index][1] = newValue;
emit('update:modelValue', newModelValue);
};
const handleAddField = () => {
emit('update:modelValue', [...props.modelValue, ['', '']]);
};
const handleDelete = (index: number) => {
const newModelValue = [...props.modelValue];
newModelValue.splice(index, 1);
emit('update:modelValue', newModelValue);
};
</script>
19 changes: 19 additions & 0 deletions src/components/KeyValueInput/__tests__/KeyValueInput.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import '@testing-library/jest-dom';
import { render } from '@testing-library/vue';
import KeyValueInput from '../KeyValueInput.vue';

const DEFAULT_PROPS: InstanceType<typeof KeyValueInput>['$props'] = {
label: 'Test label',
modelValue: [['', '']]
};

describe('KeyValueInput', () => {
it('renders', () => {
const { getByTestId } = render(KeyValueInput, { props: DEFAULT_PROPS });
expect(getByTestId('uic-key-value-input')).toBeVisible();
expect(getByTestId('uic-key-value-input-key-0')).toBeVisible();
expect(getByTestId('uic-key-value-input-value-0')).toBeVisible();
expect(getByTestId('uic-key-value-input-delete-0')).toBeVisible();
expect(getByTestId('uic-key-value-add-button')).toBeVisible();
});
});
1 change: 1 addition & 0 deletions src/components/KeyValueInput/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as KeyValueInput } from './KeyValueInput.vue';
1 change: 1 addition & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export * from './components/Grid';
export * from './components/Icon';
export * from './components/IconButton';
export * from './components/ImageFileUpload';
export * from './components/KeyValueInput';
export * from './components/Menu';
export * from './components/Modal';
export * from './components/Overlay';
Expand Down

0 comments on commit 48feccd

Please sign in to comment.