Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

feat(components): add radio button and radio group #127

Merged
merged 4 commits into from
Nov 21, 2024
Merged
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
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
Loading