Skip to content

Commit

Permalink
feat(components): add radio button and radio group (#127)
Browse files Browse the repository at this point in the history
resolve #126
  • Loading branch information
joshuagraber authored Nov 21, 2024
1 parent 9a9504e commit f928846
Show file tree
Hide file tree
Showing 13 changed files with 394 additions and 44 deletions.
1 change: 1 addition & 0 deletions docs/components.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
- [Header](../src/components/Header//README.md)
- [Input](../src/components/Input//README.md)
- [InputDatePicker](../src/components/InputDatePicker//README.md)
- [InputRadio](../src/components/InputRadio//README.md)
- [InputSelect](../src/components/InputSelect//README.md)
- [Nav](../src/components/Nav//README.md)
- [QuickSearchForm](../src/components/QuickSearchForm//README.md)
Expand Down
37 changes: 37 additions & 0 deletions src/components/InputRadio/PdapInputRadio.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<template>
<div class="pdap-input pdap-input-radio">
<input
:id="id"
:name="name"
:defaultChecked="defaultChecked"
:value="value"
v-bind="$attrs"
type="radio"
@input="onInput"
/>

<label v-if="$slots.label" :for="id"><slot name="label" /></label>
<label v-else-if="label" :for="id">{{ label }}</label>
</div>
</template>

<script setup lang="ts">
import { inject, useSlots } from 'vue';
import { PdapInputRadioProps } from './types';
import { PdapFormProvideV2 } from '../FormV2/types';
import { provideKey } from '../FormV2/util';
const { label, name } = defineProps<PdapInputRadioProps>();
const slots = useSlots();
if (!slots.label && !label)
throw new Error(
'All form inputs must have a label, passed as a slot or a prop'
);
const { setValues } = inject<PdapFormProvideV2>(provideKey)!;
function onInput(e: Event) {
setValues({ [name]: (e.target as unknown as HTMLInputElement).value });
}
</script>
53 changes: 53 additions & 0 deletions src/components/InputRadio/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# InputRadio
Radio input. Designed to be wrapped with `RadioGroup`

## Props - required

| name | required? | types | description | default |
| ---------------- | ----------------------------- | --------- | --------------------------------------------- | ------- |
| `defaultChecked` | no | `boolean` | radio is checked by default. Only 1 per group | |
| `id` | yes | `string` | id attr | |
| `label` | yes, if label slot not passed | `string` | label content | |
| `name` | yes | `string` | name attr | |

## Slots

| name | required? | types | description | default |
| ------- | ----------------------------- | --------- | ------------------------------------ | ------- |
| `label` | yes, if label prop not passed | `Element` | slot content to be rendered as label | |

## Example

```vue
<template>
<FormV2
id="form-id"
name="ice-cream-preference"
:schema="SCHEMA"
@submit="(values) => onSubmit({ values })"
@change="(values, event) => onChange({ values, event })"
>
<!-- Other inputs... -->
<RadioGroup :name="INPUT_RADIO_GROUP_NAME">
<h4 class="text-lg">
Select another flavor, with radio buttons this time!
</h4>
<InputRadio
v-for="{ label, value, defaultChecked } of ICE_CREAM_FLAVORS"
:id="value"
:key="label"
:default-checked="defaultChecked"
:name="INPUT_RADIO_GROUP_NAME"
:value="value"
:label="label"
/>
</RadioGroup>
</FormV2>
</template>
<script setup>
import { RadioGroup, InputRadio, FormV2 } from 'pdap-design-system';
</script>
...
```
1 change: 1 addition & 0 deletions src/components/InputRadio/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as InputRadio } from './PdapInputRadio.vue';
85 changes: 85 additions & 0 deletions src/components/InputRadio/input-radio.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount, VueWrapper } from '@vue/test-utils';
import PdapInputRadio from './PdapInputRadio.vue';
import { provideKey } from '../FormV2/util';
import { ref } from 'vue';

describe('PdapInputRadio', () => {
let wrapper: VueWrapper;
const mockSetValues = vi.fn();
const mockValues = ref({});
const mockV$ = ref({
testName: {
$error: false,
$errors: [],
},
});

const defaultProps = {
name: 'testName',
id: 'test-id',
label: 'Test Label',
value: 'test-value',
};

beforeEach(() => {
mockSetValues.mockClear();
});

const createWrapper = (props = {}, provide = {}, slots = {}) => {
return mount(PdapInputRadio, {
props: {
...defaultProps,
...props,
},
global: {
provide: {
[provideKey as symbol]: {
setValues: mockSetValues,
values: mockValues,
v$: mockV$,
...provide,
},
},
},
slots: { ...slots },
});
};

it('renders correctly with default props', () => {
wrapper = createWrapper();
expect(wrapper.find('input[type="radio"]').exists()).toBe(true);
expect(wrapper.find('label').text()).toBe('Test Label');
});

it('renders with slot label instead of prop label', () => {
wrapper = createWrapper(
{},
{},
{
label: '<span>Slot Label</span>',
}
);
expect(wrapper.find('label span').text()).toBe('Slot Label');
});

it('throws error when no label passed as slot or prop', async () => {
expect(() => {
wrapper = createWrapper({ label: undefined }, {}, {});
}).toThrow('All form inputs must have a label, passed as a slot or a prop');
});

it('emits input event and calls setValues when changed', async () => {
wrapper = createWrapper();
const input = wrapper.find('input');
await input.setValue(true);
expect(mockSetValues).toHaveBeenCalledWith({
[defaultProps.name]: defaultProps.value,
});
});

it('renders with defaultChecked prop', () => {
wrapper = createWrapper({ defaultChecked: true });
expect(wrapper.find('input').element.defaultChecked).toBe(true);
});
});
7 changes: 7 additions & 0 deletions src/components/InputRadio/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export interface PdapInputRadioProps {
id: string;
label?: string;
name: string;
defaultChecked?: boolean;
value: string;
}
22 changes: 22 additions & 0 deletions src/components/InputRadioGroup/PdapInputRadioGroup.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<template>
<div class="pdap-input-radio-group" :class="{ ['pdap-input-error']: error }">
<div v-if="$slots.error && error" class="pdap-input-error-message">
<slot name="error" />
</div>
<div v-else-if="error" class="pdap-input-error-message">{{ error }}</div>

<slot />
</div>
</template>

<script setup lang="ts">
import { computed, inject } from 'vue';
import { PdapFormProvideV2 } from '../FormV2/types';
import { provideKey } from '../FormV2/util';
const { name } = defineProps<{ name: string }>();
const { v$ } = inject<PdapFormProvideV2>(provideKey)!;
const error = computed(() => v$.value[name]?.$errors?.[0]?.$message);
</script>
1 change: 1 addition & 0 deletions src/components/InputRadioGroup/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as RadioGroup } from './PdapInputRadioGroup.vue';
78 changes: 78 additions & 0 deletions src/components/InputRadioGroup/input-radio-group.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount, VueWrapper } from '@vue/test-utils';
import RadioGroup from './PdapInputRadioGroup.vue';
import { provideKey } from '../FormV2/util';
import { ref } from 'vue';

describe('RadioGroup', () => {
let wrapper: VueWrapper;
const mockSetValues = vi.fn();
const mockValues = ref({});
const mockV$ = ref({
testName: {
$error: false,
$errors: [],
},
});

const defaultProps = {
name: 'testName',
};

beforeEach(() => {
mockSetValues.mockClear();
});

const createWrapper = (props = {}, slots = {}) => {
return mount(RadioGroup, {
props: {
...defaultProps,
...props,
},
slots,
global: {
provide: {
[provideKey as symbol]: {
setValues: mockSetValues,
values: mockValues,
v$: mockV$,
},
},
},
});
};

it('renders correctly with default props', () => {
wrapper = createWrapper();
expect(wrapper.find('.pdap-input-radio-group').exists()).toBe(true);
});

it('renders slot content', () => {
wrapper = createWrapper(
{},
{
default: '<div class="test-content">Test Content</div>',
}
);
expect(wrapper.find('.test-content').exists()).toBe(true);
});

it('shows error message when error exists', async () => {
mockV$.value.testName.$error = true;
// @ts-expect-error
mockV$.value.testName.$errors = [{ $message: 'Test error message' }];
wrapper = createWrapper();
expect(wrapper.find('.pdap-input-error').exists()).toBe(true);
});

it('shows custom error slot when provided and error exists', () => {
mockV$.value.testName.$error = true;
wrapper = createWrapper(
{},
{
error: '<div class="custom-error">Custom Error</div>',
}
);
expect(wrapper.find('.custom-error').exists()).toBe(true);
});
});
68 changes: 49 additions & 19 deletions src/components/InputSelect/PdapInputSelect.vue
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
/>

<div
v-else
v-if="!combobox"
class="selected-value"
:class="{ 'value-is-placeholder': !selectedOption }"
>
Expand Down Expand Up @@ -168,25 +168,55 @@ function handleClick() {
else toggleOpen();
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Tab') {
if (
!event.shiftKey &&
focusedOptionIndex.value === filteredOptions.value.length - 1
) {
event.preventDefault();
return;
}
if (event.shiftKey && focusedOptionIndex.value === 0) {
event.preventDefault();
if (combobox) isOpen.value = false;
else closeAndReturnFocus();
return;
}
// function handleKeyUp(event: KeyboardEvent) {
// if (event.key === 'Tab') {
// if (
// !event.shiftKey &&
// focusedOptionIndex.value === filteredOptions.value.length - 1
// ) {
// event.preventDefault();
// return;
// }
// if (event.shiftKey) {
// if (isOpen.value) {
// if (focusedOptionIndex.value === -1) {
// isOpen.value = false;
// }
// if (focusedOptionIndex.value === 0) {
// event.preventDefault();
// closeAndReturnFocus();
// } else {
// event.preventDefault();
// focusedOptionIndex.value = focusedOptionIndex.value - 1;
// }
// return;
// }
// }
// }
// }
return;
}
function handleKeyDown(event: KeyboardEvent) {
// if (event.key === 'Tab') {
// if (
// !event.shiftKey &&
// focusedOptionIndex.value === filteredOptions.value.length - 1
// ) {
// event.preventDefault();
// return;
// }
// if (event.shiftKey && focusedOptionIndex.value === 0) {
// event.preventDefault();
// closeAndReturnFocus();
// } else {
// event.preventDefault();
// focusedOptionIndex.value = focusedOptionIndex.value - 1;
// }
// return;
// }
if (!isOpen.value) {
if (['ArrowDown', 'ArrowUp', 'Enter'].includes(event.key)) {
Expand Down
Loading

0 comments on commit f928846

Please sign in to comment.