diff --git a/src/components/aspect/README.md b/src/components/aspect/README.md new file mode 100644 index 00000000000..59156c9d278 --- /dev/null +++ b/src/components/aspect/README.md @@ -0,0 +1,67 @@ +# Aspect + +> The `` component can be used to maintain a minimum responsive aspect ratio for content. +> When the content is longer than the available height, then the component will expand vertically to +> fit all content. If the content is shorter than the computed aspect height, the component will +> ensure a minimum height is maintained. + +## Overview + +The `` component was introduced in BootstrapVue `v2.9.0`. + +The default [aspect]() ratio is `1:1` (ratio of +`1`), which makes the height always be at least the same as the width. The `aspect` prop can be used +to specify an arbitrary aspect ratio (i.e. `1.5`) or a ratio as a string such as `'16:9'` or +`'4:3'`. + +The width will always be 100% of the available width in the parent element/component. + +```html + + + + + +``` + +## See also + +- [`` component](/docs/components/embed) for responsive embeds (videos, iframes, etc) diff --git a/src/components/aspect/aspect.js b/src/components/aspect/aspect.js new file mode 100644 index 00000000000..905a226679f --- /dev/null +++ b/src/components/aspect/aspect.js @@ -0,0 +1,56 @@ +import Vue from '../../utils/vue' +import { toFloat } from '../../utils/number' +import normalizeSlotMixin from '../../mixins/normalize-slot' + +// --- Constants --- +const NAME = 'BAspect' +const CLASS_NAME = 'b-aspect' + +const RX_ASPECT = /^\d+(\.\d*)?[/:]\d+(\.\d*)?$/ +const RX_SEPARATOR = /[/:]/ + +// --- Main Component --- +export const BAspect = /*#__PURE__*/ Vue.extend({ + name: NAME, + mixins: [normalizeSlotMixin], + props: { + aspect: { + // Accepts a number (i.e. `16 / 9`, `1`, `4 / 3`) + // Or a string (i.e. '16/9', '16:9', '4:3' '1:1') + type: [Number, String], + default: '1:1' + }, + tag: { + type: String, + default: 'div' + } + }, + computed: { + padding() { + const aspect = this.aspect + let ratio = 1 + if (RX_ASPECT.test(aspect)) { + const [width, height] = aspect.split(RX_SEPARATOR).map(v => toFloat(v) || 1) + ratio = width / height + } else { + ratio = toFloat(aspect) || 1 + } + return `${100 / Math.abs(ratio)}%` + } + }, + render(h) { + const $sizer = h('div', { + staticClass: `${CLASS_NAME}-sizer flex-grow-1`, + style: { paddingBottom: this.padding, height: 0 } + }) + const $content = h( + 'div', + { + staticClass: `${CLASS_NAME}-content flex-grow-1 w-100 mw-100`, + style: { marginLeft: '-100%' } + }, + [this.normalizeSlot('default')] + ) + return h(this.tag, { staticClass: `${CLASS_NAME} d-flex` }, [$sizer, $content]) + } +}) diff --git a/src/components/aspect/aspect.spec.js b/src/components/aspect/aspect.spec.js new file mode 100644 index 00000000000..66e003c2c59 --- /dev/null +++ b/src/components/aspect/aspect.spec.js @@ -0,0 +1,117 @@ +import { mount } from '@vue/test-utils' +import { BAspect } from './aspect' + +describe('aspect', () => { + it('should have expected default structure', async () => { + const wrapper = mount(BAspect) + expect(wrapper.isVueInstance()).toBe(true) + expect(wrapper.is('div')).toBe(true) + expect(wrapper.classes()).toContain('b-aspect') + expect(wrapper.classes()).toContain('d-flex') + expect(wrapper.classes().length).toBe(2) + + const $sizer = wrapper.find('.b-aspect-sizer') + expect($sizer.exists()).toBe(true) + expect($sizer.is('div')).toBe(true) + expect($sizer.classes()).toContain('flex-grow-1') + // Default aspect ratio is 1:1 + expect($sizer.attributes('style')).toContain('padding-bottom: 100%;') + + const $content = wrapper.find('.b-aspect-content') + expect($content.exists()).toBe(true) + expect($content.is('div')).toBe(true) + expect($content.classes()).toContain('flex-grow-1') + expect($content.classes()).toContain('w-100') + expect($content.classes()).toContain('mw-100') + expect($content.attributes('style')).toContain('margin-left: -100%;') + + wrapper.destroy() + }) + + it('should have expected structure when prop `tag` is set', async () => { + const wrapper = mount(BAspect, { + propsData: { + tag: 'section' + } + }) + expect(wrapper.isVueInstance()).toBe(true) + expect(wrapper.is('section')).toBe(true) + expect(wrapper.classes()).toContain('b-aspect') + expect(wrapper.classes()).toContain('d-flex') + expect(wrapper.classes().length).toBe(2) + + const $sizer = wrapper.find('.b-aspect-sizer') + expect($sizer.exists()).toBe(true) + expect($sizer.is('div')).toBe(true) + expect($sizer.classes()).toContain('flex-grow-1') + // Default aspect ratio is 1:1 + expect($sizer.attributes('style')).toContain('padding-bottom: 100%;') + + const $content = wrapper.find('.b-aspect-content') + expect($content.exists()).toBe(true) + expect($content.is('div')).toBe(true) + expect($content.classes()).toContain('flex-grow-1') + expect($content.classes()).toContain('w-100') + expect($content.classes()).toContain('mw-100') + expect($content.attributes('style')).toContain('margin-left: -100%;') + + wrapper.destroy() + }) + + it('should have expected structure when aspect is set to "4:3"', async () => { + const wrapper = mount(BAspect, { + propsData: { + aspect: '4:3' + } + }) + expect(wrapper.isVueInstance()).toBe(true) + expect(wrapper.is('div')).toBe(true) + expect(wrapper.classes()).toContain('b-aspect') + expect(wrapper.classes()).toContain('d-flex') + expect(wrapper.classes().length).toBe(2) + + const $sizer = wrapper.find('.b-aspect-sizer') + expect($sizer.exists()).toBe(true) + expect($sizer.is('div')).toBe(true) + expect($sizer.classes()).toContain('flex-grow-1') + expect($sizer.attributes('style')).toContain('padding-bottom: 75%;') + + const $content = wrapper.find('.b-aspect-content') + expect($content.exists()).toBe(true) + expect($content.is('div')).toBe(true) + expect($content.classes()).toContain('flex-grow-1') + expect($content.classes()).toContain('w-100') + expect($content.classes()).toContain('mw-100') + expect($content.attributes('style')).toContain('margin-left: -100%;') + + wrapper.destroy() + }) + it('should have expected structure when aspect is set to `16/9`', async () => { + const wrapper = mount(BAspect, { + propsData: { + aspect: 16 / 9 + } + }) + expect(wrapper.isVueInstance()).toBe(true) + expect(wrapper.is('div')).toBe(true) + expect(wrapper.classes()).toContain('b-aspect') + expect(wrapper.classes()).toContain('d-flex') + expect(wrapper.classes().length).toBe(2) + + const $sizer = wrapper.find('.b-aspect-sizer') + expect($sizer.exists()).toBe(true) + expect($sizer.is('div')).toBe(true) + expect($sizer.classes()).toContain('flex-grow-1') + expect($sizer.attributes('style')).toContain('padding-bottom: 56.25%;') + + const $content = wrapper.find('.b-aspect-content') + expect($content.exists()).toBe(true) + expect($content.is('div')).toBe(true) + expect($content.classes()).toContain('flex-grow-1') + expect($content.classes()).toContain('w-100') + expect($content.classes()).toContain('mw-100') + expect($content.attributes('style')).toContain('margin-left: -100%;') + + wrapper.destroy() + }) +}) diff --git a/src/components/aspect/index.d.ts b/src/components/aspect/index.d.ts new file mode 100644 index 00000000000..d2113036ca6 --- /dev/null +++ b/src/components/aspect/index.d.ts @@ -0,0 +1,11 @@ +// +// Aspect +// +import Vue from 'vue' +import { BvPlugin, BvComponent } from '../../' + +// Plugin +export declare const AspectPlugin: BvPlugin + +// Component: b-aspect +export declare class BAspect extends BvComponent {} diff --git a/src/components/aspect/index.js b/src/components/aspect/index.js new file mode 100644 index 00000000000..82ca1cf7233 --- /dev/null +++ b/src/components/aspect/index.js @@ -0,0 +1,8 @@ +import { BAspect } from './aspect' +import { pluginFactory } from '../../utils/plugins' + +const AspectPlugin = /*#__PURE__*/ pluginFactory({ + components: { BAspect } +}) + +export { AspectPlugin, BAspect } diff --git a/src/components/aspect/package.json b/src/components/aspect/package.json new file mode 100644 index 00000000000..5a3440f7a3e --- /dev/null +++ b/src/components/aspect/package.json @@ -0,0 +1,21 @@ +{ + "name": "@bootstrap-vue/aspect", + "version": "1.0.0", + "meta": { + "title": "Aspect", + "new": true, + "version": "2.9.0", + "description": "The `` component can be used to maintain a minimum responsive aspect ratio for content.", + "components": [ + { + "component": "BAspect", + "props": [ + { + "prop": "aspect", + "description": "Aspect as a width to height numeric ratio (such as `1.5`) or `width:height` string (such as '16:9')" + } + ] + } + ] + } +} diff --git a/src/components/embed/README.md b/src/components/embed/README.md index 5957a3cb006..5ac71ad8672 100644 --- a/src/components/embed/README.md +++ b/src/components/embed/README.md @@ -56,4 +56,8 @@ embedded element. Note that the type `iframe` does not support any children. ``` +## See also + +- [`` component](/docs/components/aspect) + diff --git a/src/components/form-spinbutton/README.md b/src/components/form-spinbutton/README.md index c6c9637e979..e72da2c1dc9 100644 --- a/src/components/form-spinbutton/README.md +++ b/src/components/form-spinbutton/README.md @@ -4,7 +4,11 @@ > incrementing or decrementing a numerical value within a range of a minimum and maximum number, > with optional step value. -`` is +## Overview + +`` was introduced in BootstrapVue `v2.5.0`. + +The component `` is [WAI-ARIA compliant](https://www.w3.org/TR/wai-aria-practices-1.2/#spinbutton), allowing for [keyboard control](#accessibility), and supports both horizontal (default) and vertical layout. @@ -33,8 +37,6 @@ Similar to [range type inputs](/docs/components/form-input#range-type-input), Bo ``` -## Overview - The ArrowUp and ArrowDown keys can be used to increment or decrement the value. diff --git a/src/components/form-spinbutton/package.json b/src/components/form-spinbutton/package.json index 523f599eb94..d27b987de99 100644 --- a/src/components/form-spinbutton/package.json +++ b/src/components/form-spinbutton/package.json @@ -3,7 +3,6 @@ "version": "1.0.0", "meta": { "title": "Form Spinbutton", - "new": true, "version": "2.5.0", "description": "BootstrapVue custom numerical spinbutton form input component, featuring WAI-ARIA accessibility (a11y) and internationalization (i18n)", "components": [ diff --git a/src/components/index.d.ts b/src/components/index.d.ts index 102a27b47bb..5b3c3d1c7d0 100644 --- a/src/components/index.d.ts +++ b/src/components/index.d.ts @@ -5,6 +5,7 @@ export declare const componentsPlugin: BvPlugin // Export all components as named exports export * from './alert' +export * from './aspect' export * from './avatar' export * from './badge' export * from './breadcrumb' diff --git a/src/components/index.js b/src/components/index.js index 8b876cbc1d6..74e47073bb6 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -2,6 +2,7 @@ import { pluginFactory } from '../utils/plugins' // Component group plugins import { AlertPlugin } from './alert' +import { AspectPlugin } from './aspect' import { AvatarPlugin } from './avatar' import { BadgePlugin } from './badge' import { BreadcrumbPlugin } from './breadcrumb' @@ -53,6 +54,7 @@ import { TooltipPlugin } from './tooltip' export const componentsPlugin = /*#__PURE__*/ pluginFactory({ plugins: { AlertPlugin, + AspectPlugin, AvatarPlugin, BadgePlugin, BreadcrumbPlugin, diff --git a/src/index.js b/src/index.js index 8dfecef73db..80c4ce9eac1 100644 --- a/src/index.js +++ b/src/index.js @@ -67,6 +67,10 @@ export * from './icons/icons' export { AlertPlugin } from './components/alert' export { BAlert } from './components/alert/alert' +// export * from './components/aspect' +export { AspectPlugin } from './components/aspect' +export { BAspect } from './components/aspect/aspect' + // export * from './components/avatar' export { AvatarPlugin } from './components/avatar' export { BAvatar } from './components/avatar/avatar'