From 2fe4e69747c0c4b7a8f518d9d8c09fe673d56c72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81ngel=20Rold=C3=A1n=20Mart=C3=ADnez?= Date: Tue, 2 Apr 2024 17:00:22 +0200 Subject: [PATCH] fix(BFormFile): add properties placement and browser as in BootstrapVue (#1797) feat(BFormFile): add properties placement and browser as in BootstrapVue test(BFormFile): add unit tests --- .../docs/src/data/components/formFile.data.ts | 57 +- .../src/components/BFormFile/BFormFile.vue | 93 +++- .../components/BFormFile/form-file.spec.ts | 507 ++++++++++++++++++ .../src/components/BFormGroup/BFormGroup.vue | 5 +- .../src/composables/useModalManager.ts | 4 +- 5 files changed, 637 insertions(+), 29 deletions(-) create mode 100644 packages/bootstrap-vue-next/src/components/BFormFile/form-file.spec.ts diff --git a/apps/docs/src/data/components/formFile.data.ts b/apps/docs/src/data/components/formFile.data.ts index b8a63703d..c388c8a73 100644 --- a/apps/docs/src/data/components/formFile.data.ts +++ b/apps/docs/src/data/components/formFile.data.ts @@ -5,107 +5,158 @@ export default { { component: 'BFormFile', props: [ + { + prop: 'ariaLabel', + type: 'string', + default: undefined, + description: 'Sets the value of `aria-label` attribute on the rendered element', + }, + { + prop: 'ariaLabelledBy', + type: 'string', + default: undefined, + description: + 'The ID of the element that provides a label for this component. Used as the value for the `aria-labelledby` attribute', + }, { prop: 'accept', type: 'string | string[]', default: '', + description: "Value to set on the file input's `accept` attribute", }, { prop: 'autofocus', type: 'boolean', default: false, + description: + 'When set to `true`, attempts to auto-focus the control when it is mounted, or re-activated when in a keep-alive. Does not set the `autofocus` attribute on the control', }, { prop: 'capture', type: "'boolean' | 'user' | 'environment'", default: false, + description: + 'When set, will instruction the browser to use the devices camera (if supported)', }, { prop: 'directory', type: 'boolean', default: false, + description: 'Enable `directory` mode (on browsers that support it)', }, { prop: 'disabled', type: 'boolean', default: false, + description: + "When set to `true`, disables the component's functionality and places it in a disabled state", }, { prop: 'form', type: 'string', default: undefined, + description: + 'ID of the form that the form control belongs to. Sets the `form` attribute on the control', }, { prop: 'id', type: 'string', default: undefined, + description: + 'Used to set the `id` attribute on the rendered content, and used as the base to generate any additional element IDs as needed', }, { prop: 'multiple', type: 'boolean', default: false, + description: + 'When set, will allow multiple files to be selected. `v-model` will be an array', }, { prop: 'name', type: 'string', default: undefined, + description: 'Sets the value of the `name` attribute on the form control', }, { prop: 'noDrop', type: 'boolean', default: false, + description: 'Disable drag and drop mode', }, { prop: 'noTraverse', type: 'boolean', default: false, + description: 'Wether to returns files as a flat array when in `directory` mode', }, { prop: 'required', type: 'boolean', default: false, + description: 'Adds the `required` attribute to the form control', }, { prop: 'size', type: 'Size', default: undefined, + description: "Set the size of the component's appearance. 'sm', 'md' (default), or 'lg'", }, { prop: 'state', type: 'boolean | null', default: undefined, + description: + 'Controls the validation state appearance of the component. `true` for valid, `false` for invalid, or `null` for no validation state', }, { prop: 'modelValue', type: 'File[] | File | null', default: undefined, + description: + 'The current value of the file input. Will be a single `File` object or an array of `File` objects (if `multiple` or `directory` is set). Can be set to `null`, or an empty array to reset the file input', }, { prop: 'label', type: 'string', default: '', + description: 'Sets the label for the form group which the file input is rendered', }, { prop: 'labelClass', type: 'ClassValue', default: undefined, + description: 'Sets the styling for the label', + }, + { + prop: 'placement', + type: `'start' | 'end'`, + default: `'start'`, + description: 'Sets the placement for the file button', + }, + { + prop: 'browser', + type: 'String', + default: 'Browse', + description: 'Text content for the file browse button', }, ], emits: [ { event: 'update:modelValue', - description: '', + description: 'Updates the `v-model` value (see docs for more details)', args: [ { arg: 'value', type: 'File | File[] | null', - description: '', + description: + 'Will be a single File object in single mode or an array of File objects in multiple mode', }, ], }, { event: 'change', - description: '', + description: 'Original change event of the input', args: [ { arg: 'value', diff --git a/packages/bootstrap-vue-next/src/components/BFormFile/BFormFile.vue b/packages/bootstrap-vue-next/src/components/BFormFile/BFormFile.vue index abaf9057a..1f4ae083a 100644 --- a/packages/bootstrap-vue-next/src/components/BFormFile/BFormFile.vue +++ b/packages/bootstrap-vue-next/src/components/BFormFile/BFormFile.vue @@ -1,36 +1,49 @@ + + diff --git a/packages/bootstrap-vue-next/src/components/BFormFile/form-file.spec.ts b/packages/bootstrap-vue-next/src/components/BFormFile/form-file.spec.ts new file mode 100644 index 000000000..6aa4f7b4b --- /dev/null +++ b/packages/bootstrap-vue-next/src/components/BFormFile/form-file.spec.ts @@ -0,0 +1,507 @@ +import { enableAutoUnmount, mount } from '@vue/test-utils' +import { afterEach, describe, expect, it } from 'vitest' +import BFormFile from './BFormFile.vue' + +describe('form-file', () => { + enableAutoUnmount(afterEach) + + it('tag is default div', () => { + const wrapper = mount(BFormFile) + expect(wrapper.element.tagName).toBe('DIV') + }) + + it('has class form-control-{type} when prop size', () => { + const wrapper = mount(BFormFile, { + props: {size: 'lg'}, + }) + + const $input = wrapper.find('input') + expect($input.classes()).toContain('form-control-lg') + }) + + it('does not have class form-control-{type} when prop size undefined', () => { + const wrapper = mount(BFormFile, { + props: {size: undefined}, + }) + + const $input = wrapper.find('input') + expect($input.classes()).not.toContain('form-control-lg') + }) + + it('wrapper has class form-input-file', () => { + const wrapper = mount(BFormFile) + expect(wrapper.find('div').classes()).toContain('form-input-file') + }) + + describe('input attributes', () => { + it('has input element', () => { + const wrapper = mount(BFormFile) + const $input = wrapper.find('input') + expect($input.exists()).toBe(true) + }) + + it('input element has attr id', () => { + const wrapper = mount(BFormFile) + const $input = wrapper.get('input') + expect($input.attributes('id')).toBeDefined() + }) + + it('input element attr id contains content from prop id', () => { + const wrapper = mount(BFormFile, { + props: {id: 'foobar'}, + }) + const $input = wrapper.get('input') + expect($input.attributes('id')).toBe('foobar') + }) + + it('input element has attr type to be file', () => { + const wrapper = mount(BFormFile) + const $input = wrapper.get('input') + expect($input.attributes('type')).toBe('file') + }) + + it('input element has attr disabled when prop disabled', () => { + const wrapper = mount(BFormFile, { + props: {disabled: true}, + }) + const $input = wrapper.get('input') + expect($input.attributes('disabled')).toBe('') + }) + + it('input element does not have attr disabled when prop disabled is false', () => { + const wrapper = mount(BFormFile, { + props: {disabled: false}, + }) + const $input = wrapper.get('input') + expect($input.attributes('disabled')).toBeUndefined() + }) + + it('input element does not have attr disabled when prop disabled is undefined', () => { + const wrapper = mount(BFormFile, { + props: {disabled: undefined}, + }) + const $input = wrapper.get('input') + expect($input.attributes('disabled')).toBeUndefined() + }) + + it('input element has attr required when prop name and prop required', () => { + const wrapper = mount(BFormFile, { + props: {required: true, name: 'foo'}, + }) + const $input = wrapper.get('input') + expect($input.attributes('required')).toBe('') + }) + + it('input element does not have attr required when prop name is empty string and prop required', () => { + const wrapper = mount(BFormFile) + const $input = wrapper.get('input') + expect($input.attributes('required')).toBeUndefined() + }) + + it('input element has attr name to be prop name', () => { + const wrapper = mount(BFormFile, { + props: {name: 'foobar'}, + }) + const $input = wrapper.get('input') + expect($input.attributes('name')).toBe('foobar') + }) + + it('input element has attr name is undefined when prop name undefined', () => { + const wrapper = mount(BFormFile, { + props: {name: undefined}, + }) + const $input = wrapper.get('input') + expect($input.attributes('name')).toBeUndefined() + }) + + it('input element has attr form to be prop form', () => { + const wrapper = mount(BFormFile, { + props: {form: 'foobar'}, + }) + const $input = wrapper.get('input') + expect($input.attributes('form')).toBe('foobar') + }) + + it('input element has attr form is undefined when prop form undefined', () => { + const wrapper = mount(BFormFile, { + props: {form: undefined}, + }) + const $input = wrapper.get('input') + expect($input.attributes('form')).toBeUndefined() + }) + + it('input element has attr aria-label to be prop ariaLabel', () => { + const wrapper = mount(BFormFile, { + props: {ariaLabel: 'foobar'}, + }) + const $input = wrapper.get('input') + expect($input.attributes('aria-label')).toBe('foobar') + }) + + it('input element has attr aria-label is undefined when prop ariaLabel undefined', () => { + const wrapper = mount(BFormFile, { + props: {ariaLabel: undefined}, + }) + const $input = wrapper.get('input') + expect($input.attributes('aria-label')).toBeUndefined() + }) + + it('input element has attr aria-labelledby to be prop ariaLabelledby', () => { + const wrapper = mount(BFormFile, { + props: {ariaLabelledby: 'foobar'}, + }) + const $input = wrapper.get('input') + expect($input.attributes('aria-labelledby')).toBe('foobar') + }) + + it('input element has attr aria-labelledby is undefined when prop ariaLabelledby undefined', () => { + const wrapper = mount(BFormFile, { + props: {ariaLabelledby: undefined}, + }) + const $input = wrapper.get('input') + expect($input.attributes('aria-labelledby')).toBeUndefined() + }) + + it('input element has attr aria-labelledby is undefined when prop ariaLabelledby undefined', () => { + const wrapper = mount(BFormFile, { + props: {ariaLabelledby: undefined}, + }) + const $input = wrapper.get('input') + expect($input.attributes('aria-labelledby')).toBeUndefined() + }) + + it('input element has attr value to be true when value is undefined', () => { + const wrapper = mount(BFormFile, { + props: {value: undefined}, + }) + const $input = wrapper.get('input') + expect($input.attributes('value')).toBeUndefined() + }) + + it('input element aria-required when prop name and prop required true', () => { + const wrapper = mount(BFormFile, { + props: {name: 'foo', required: true}, + }) + const $input = wrapper.get('input') + expect($input.attributes('aria-required')).toBe('true') + }) + + it('input element does not have aria-required when prop name and prop required false', () => { + const wrapper = mount(BFormFile, { + props: {name: 'foo', required: false}, + }) + const $input = wrapper.get('input') + expect($input.attributes('aria-required')).toBeUndefined() + }) + + it('input element has set attr multiple to true when prop multiple is true', () => { + const wrapper = mount(BFormFile, { + props: {multiple: 'true'}, + }) + const $input = wrapper.get('input') + expect($input.attributes('multiple')).toBeDefined() + }) + + it('input element has set attr multiple to false when prop multiple is false', () => { + const wrapper = mount(BFormFile, { + props: {multiple: false}, + }) + const $input = wrapper.get('input') + expect($input.attributes('multiple')).toBeUndefined() + }) + + it('input element has set attr multiple to false when prop multiple is undefined', () => { + const wrapper = mount(BFormFile, { + props: {multiple: undefined}, + }) + const $input = wrapper.get('input') + expect($input.attributes('multiple')).toBeUndefined() + }) + + it('input element has set attr capture to true when prop capture is true', () => { + const wrapper = mount(BFormFile, { + props: {capture: true}, + }) + const $input = wrapper.get('input') + expect($input.attributes('capture')).toBe('true') + }) + + it('input element has set attr capture to false when prop capture is false', () => { + const wrapper = mount(BFormFile, { + props: {capture: false}, + }) + const $input = wrapper.get('input') + expect($input.attributes('capture')).toBe('false') + }) + + it('input element has set attr capture to false when prop disabled is undefined', () => { + const wrapper = mount(BFormFile, { + props: {capture: undefined}, + }) + const $input = wrapper.get('input') + expect($input.attributes('capture')).toBe('false') + }) + + it('input element has set attr accept to empty when prop accept is true', () => { + const wrapper = mount(BFormFile, { + props: {accept: ''}, + }) + const $input = wrapper.get('input') + expect($input.attributes('accept')).toBeUndefined() + }) + + it('input element has set attr accept to "foo" when prop accept is false', () => { + const wrapper = mount(BFormFile, { + props: {accept: 'foo'}, + }) + const $input = wrapper.get('input') + expect($input.attributes('accept')).toBe('foo') + }) + + it('input element does not have attr accept when prop accept is undefined', () => { + const wrapper = mount(BFormFile, { + props: {accept: undefined}, + }) + const $input = wrapper.get('input') + expect($input.attributes('accept')).toBeUndefined() + }) + + it('input element has set attr directory to true when prop directory is true', () => { + const wrapper = mount(BFormFile, { + props: {directory: true}, + }) + const $input = wrapper.get('input') + expect($input.attributes('directory')).toBe('true') + }) + + it('input element has set attr directory to false when prop directory is false', () => { + const wrapper = mount(BFormFile, { + props: {directory: false}, + }) + const $input = wrapper.get('input') + expect($input.attributes('directory')).toBe('false') + }) + + it('input element has set attr directory to false when prop directory is undefined', () => { + const wrapper = mount(BFormFile, { + props: {directory: undefined}, + }) + const $input = wrapper.get('input') + expect($input.attributes('directory')).toBe('false') + }) + + it('default has custom attributes transferred input element', async () => { + const wrapper = mount(BFormFile, { + propsData: { + id: 'foo', + foo: 'bar', + }, + }) + + const $input = wrapper.find('input') + expect($input.attributes('foo')).toBeDefined() + expect($input.attributes('foo')).toEqual('bar') + }) + }) + + describe('label attributes', () => { + it('has label by default', () => { + const wrapper = mount(BFormFile) + const $label = wrapper.find('label') + expect($label.exists()).toBe(true) + }) + + it('has label when has label slot defined', () => { + const wrapper = mount(BFormFile, { + props: {labelClass: 'labelClass'}, + slots: {label: 'foo'}, + }) + const $label = wrapper.get('label') + expect($label).not.toBeUndefined() + expect($label.attributes('class')?.includes('labelClass')) + }) + it('has label has attr for to be defined by default', () => { + const wrapper = mount(BFormFile, { + props: {id: 'fooFile'}, + slots: {default: 'foo'}, + }) + + const $input = wrapper.get('input') + const $label = wrapper.get('label') + + const $labelFor = $label.attributes('for') + const $inputId = $input.attributes('id') + + expect($labelFor).toBe($inputId) + }) + }) + + describe('file button', () => { + it('the button is placed on the start when prop placement is start', () => { + const wrapper = mount(BFormFile, { + props: {id: 'fooFile', placement: 'start'}, + }) + + const $label = wrapper.get('label') + expect($label.classes()).toContain('input-group-text') + + const childElements = wrapper.get('div').findAll('*') + + expect(childElements[0].exists()) + expect(childElements[0].element.tagName).toBe('LABEL') + + expect(childElements[1].exists()) + expect(childElements[1].element.tagName).toBe('INPUT') + }) + it('the button is placed on the end when prop placement is end', () => { + const wrapper = mount(BFormFile, { + props: {id: 'fooFile', placement: 'end'}, + }) + + const $label = wrapper.get('label') + expect($label.classes()).not.toContain('form-label') + expect($label.classes()).toContain('input-group-text') + + const childElements = wrapper.get('div').findAll('*') + + expect(childElements[0].exists()) + expect(childElements[0].element.tagName).toBe('INPUT') + + expect(childElements[1].exists()) + expect(childElements[1].element.tagName).toBe('LABEL') + }) + it('the button is placed on the start when prop placement is undefined', () => { + const wrapper = mount(BFormFile, { + props: {id: 'fooFile'}, + }) + + const $label = wrapper.get('label') + expect($label.classes()).not.toContain('form-label') + expect($label.classes()).toContain('input-group-text') + + const childElements = wrapper.get('div').findAll('*') + + expect(childElements[0].exists()) + expect(childElements[0].element.tagName).toBe('LABEL') + + expect(childElements[1].exists()) + expect(childElements[1].element.tagName).toBe('INPUT') + }) + + it('the button has value text when prop browser text is defined', () => { + const wrapper = mount(BFormFile, { + props: {browserText: 'Browse'}, + }) + + const $label = wrapper.get('label') + expect($label.classes()).not.toContain('form-label') + expect($label.classes()).toContain('input-group-text') + expect($label.text()).toBe('Browse') + }) + it('the button has `Choose` when prop browser text is undefined', () => { + const wrapper = mount(BFormFile) + + const $label = wrapper.get('label') + expect($label.classes()).not.toContain('form-label') + expect($label.classes()).toContain('input-group-text') + expect($label.text()).toBe('Choose') + }) + }) + + describe('model behavior', () => { + it('emits input even when file changed', async () => { + const file = new File(['foo'], 'foo.txt', { + type: 'text/plain', + lastModified: Date.now(), + }) + + const wrapper = mount(BFormFile, {props: {id: 'foo', modelValue: file}}) + + const $input = wrapper.get('input') + await $input.trigger('change') + + expect(wrapper.emitted('change')).toBeDefined() + expect(wrapper.emitted('change')).toHaveLength(1) + expect(wrapper.emitted('change')?.[0][0]).toBeInstanceOf(Event) + }) + + it('emits update:modelValue===file event when file changed', async () => { + const file = new File(['foo'], 'foo.txt', { + type: 'text/plain', + lastModified: Date.now(), + }) + + const dataTransfer = new ClipboardEvent('').clipboardData ?? new DataTransfer() + dataTransfer.items.add(file) + + const wrapper = mount(BFormFile, {props: {id: 'foo', modelValue: undefined}}) + + const $input = wrapper.get('input') + $input.element.files = dataTransfer.files + await $input.trigger('change') + + expect(wrapper.emitted('update:modelValue')).toBeDefined() + expect(wrapper.emitted('update:modelValue')).toHaveLength(1) + expect(wrapper.emitted('update:modelValue')?.[0][0]).toStrictEqual(file) + }) + + it('emits update:modelValue===file event when file changed in multiple mode', async () => { + const file1 = new File(['foo'], 'foo.txt', { + type: 'text/plain', + lastModified: Date.now(), + }) + + const file2 = new File(['foo2'], 'foo2.txt', { + type: 'text/plain', + lastModified: Date.now(), + }) + + const dataTransfer = new ClipboardEvent('').clipboardData ?? new DataTransfer() + dataTransfer.items.add(file1) + dataTransfer.items.add(file2) + + const wrapper = mount(BFormFile, {props: {id: 'foo', modelValue: undefined, multiple: true}}) + + const $input = wrapper.get('input') + $input.element.files = dataTransfer.files + await $input.trigger('change') + + expect(wrapper.emitted('update:modelValue')).toBeDefined() + expect(wrapper.emitted('update:modelValue')).toHaveLength(1) + expect((wrapper.emitted('update:modelValue')?.[0][0] as File[])[0]).toStrictEqual(file1) + expect((wrapper.emitted('update:modelValue')?.[0][0] as File[])[1]).toStrictEqual(file2) + }) + + it('emits update:modelValue===file event when file changed in directory mode', async () => { + const file1 = new File(['foo'], 'foo.txt', { + type: 'text/plain', + lastModified: Date.now(), + }) + + const file2 = new File(['foo2'], 'foo2.txt', { + type: 'text/plain', + lastModified: Date.now(), + }) + + const dataTransfer = new ClipboardEvent('').clipboardData ?? new DataTransfer() + dataTransfer.items.add(file1) + dataTransfer.items.add(file2) + + const wrapper = mount(BFormFile, { + props: {id: 'foo', modelValue: undefined, multiple: true, directory: true}, + }) + + const $input = wrapper.get('input') + $input.element.files = dataTransfer.files + await $input.trigger('change') + + expect(wrapper.emitted('update:modelValue')).toBeDefined() + expect(wrapper.emitted('update:modelValue')).toHaveLength(1) + expect((wrapper.emitted('update:modelValue')?.[0][0] as File[])[0]).toStrictEqual(file1) + expect((wrapper.emitted('update:modelValue')?.[0][0] as File[])[1]).toStrictEqual(file2) + }) + + it('reset() method works in single mode', async () => {}) + it('reset() method works in multiple mode', async () => {}) + }) +}) diff --git a/packages/bootstrap-vue-next/src/components/BFormGroup/BFormGroup.vue b/packages/bootstrap-vue-next/src/components/BFormGroup/BFormGroup.vue index b1983ccac..b99f7134a 100644 --- a/packages/bootstrap-vue-next/src/components/BFormGroup/BFormGroup.vue +++ b/packages/bootstrap-vue-next/src/components/BFormGroup/BFormGroup.vue @@ -162,7 +162,10 @@ export default defineComponent({ const stateClass = useStateClass(() => props.state) - const computedAriaInvalid = useAriaInvalid(() => props.ariaInvalid, () => props.state) + const computedAriaInvalid = useAriaInvalid( + () => props.ariaInvalid, + () => props.state + ) watch( () => ariaDescribedby, diff --git a/packages/bootstrap-vue-next/src/composables/useModalManager.ts b/packages/bootstrap-vue-next/src/composables/useModalManager.ts index 1bf8bf327..4f4aa664d 100644 --- a/packages/bootstrap-vue-next/src/composables/useModalManager.ts +++ b/packages/bootstrap-vue-next/src/composables/useModalManager.ts @@ -82,8 +82,8 @@ export default (modalOpen: Readonly>) => { ) return { - activePosition: computed(() => - stack?.value.findIndex((el) => el.exposed?.id === currentModal.exposed?.id) + activePosition: computed( + () => stack?.value.findIndex((el) => el.exposed?.id === currentModal.exposed?.id) ), activeModalCount: countStack, }