Skip to content

Commit 662c8e0

Browse files
authored
feat(b-aspect): new custom component <b-aspect> (#5008)
Co-authored-by: Jacob Müller
1 parent ee6fe52 commit 662c8e0

File tree

12 files changed

+296
-4
lines changed

12 files changed

+296
-4
lines changed

src/components/aspect/README.md

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# Aspect
2+
3+
> The `<b-aspect>` component can be used to maintain a minimum responsive aspect ratio for content.
4+
> When the content is longer than the available height, then the component will expand vertically to
5+
> fit all content. If the content is shorter than the computed aspect height, the component will
6+
> ensure a minimum height is maintained.
7+
8+
## Overview
9+
10+
The `<b-aspect>` component was introduced in BootstrapVue `v2.9.0`.
11+
12+
The default [aspect](<https://en.wikipedia.org/wiki/Aspect_ratio_(image)>) ratio is `1:1` (ratio of
13+
`1`), which makes the height always be at least the same as the width. The `aspect` prop can be used
14+
to specify an arbitrary aspect ratio (i.e. `1.5`) or a ratio as a string such as `'16:9'` or
15+
`'4:3'`.
16+
17+
The width will always be 100% of the available width in the parent element/component.
18+
19+
```html
20+
<template>
21+
<div>
22+
<b-form-group label="Aspect ratio" label-for="ratio" label-cols-md="auto" class="mb-3">
23+
<b-form-select id="ratio" v-model="aspect" :options="aspects"></b-form-input>
24+
</b-form-group>
25+
<b-card>
26+
<b-aspect :aspect="aspect">
27+
This will always be an aspect of "{{ aspect }}",
28+
except when the content is too tall.
29+
</b-aspect>
30+
</b-card>
31+
</div>
32+
</template>
33+
34+
<script>
35+
export default {
36+
data() {
37+
return {
38+
aspect: '16:9',
39+
aspects: [
40+
{ text: '4:3 (SD)', value: '4:3' },
41+
{ text: '1:1 (Square)', value: '1:1' },
42+
{ text: '16:9 (HD)', value: '16:9' },
43+
{ text: '1.85:1 (Widescreen)', value: '1.85:1' },
44+
{ text: '2:1 (Univisium/Superscope)', value: '2:1' },
45+
{ text: '21:9 (Anamorphic)', value: '21:9' },
46+
{ text: '1.43:1 (IMAX)', value: '1.43:1' },
47+
{ text: '3:2 (35mm Film)', value: '3:2' },
48+
{ text: '3:1 (APS-P)', value: '3:1' },
49+
{ text: '4/3 (Same as 4:3)', value: 4 / 3 },
50+
{ text: '16/9 (Same as 16:9)', value: 16 / 9 },
51+
{ text: '3 (Same as 3:1)', value: 3 },
52+
{ text: '2 (Same as 2:1)', value: 2 },
53+
{ text: '1.85 (Same as 1.85:1)', value: 1.85 },
54+
{ text: '1.5', value: 1.5 },
55+
{ text: '1 (Same as 1:1)', value: 1 }
56+
]
57+
}
58+
}
59+
}
60+
</script>
61+
62+
<!-- b-aspect.vue -->
63+
```
64+
65+
## See also
66+
67+
- [`<b-embed>` component](/docs/components/embed) for responsive embeds (videos, iframes, etc)

src/components/aspect/aspect.js

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import Vue from '../../utils/vue'
2+
import { toFloat } from '../../utils/number'
3+
import normalizeSlotMixin from '../../mixins/normalize-slot'
4+
5+
// --- Constants ---
6+
const NAME = 'BAspect'
7+
const CLASS_NAME = 'b-aspect'
8+
9+
const RX_ASPECT = /^\d+(\.\d*)?[/:]\d+(\.\d*)?$/
10+
const RX_SEPARATOR = /[/:]/
11+
12+
// --- Main Component ---
13+
export const BAspect = /*#__PURE__*/ Vue.extend({
14+
name: NAME,
15+
mixins: [normalizeSlotMixin],
16+
props: {
17+
aspect: {
18+
// Accepts a number (i.e. `16 / 9`, `1`, `4 / 3`)
19+
// Or a string (i.e. '16/9', '16:9', '4:3' '1:1')
20+
type: [Number, String],
21+
default: '1:1'
22+
},
23+
tag: {
24+
type: String,
25+
default: 'div'
26+
}
27+
},
28+
computed: {
29+
padding() {
30+
const aspect = this.aspect
31+
let ratio = 1
32+
if (RX_ASPECT.test(aspect)) {
33+
const [width, height] = aspect.split(RX_SEPARATOR).map(v => toFloat(v) || 1)
34+
ratio = width / height
35+
} else {
36+
ratio = toFloat(aspect) || 1
37+
}
38+
return `${100 / Math.abs(ratio)}%`
39+
}
40+
},
41+
render(h) {
42+
const $sizer = h('div', {
43+
staticClass: `${CLASS_NAME}-sizer flex-grow-1`,
44+
style: { paddingBottom: this.padding, height: 0 }
45+
})
46+
const $content = h(
47+
'div',
48+
{
49+
staticClass: `${CLASS_NAME}-content flex-grow-1 w-100 mw-100`,
50+
style: { marginLeft: '-100%' }
51+
},
52+
[this.normalizeSlot('default')]
53+
)
54+
return h(this.tag, { staticClass: `${CLASS_NAME} d-flex` }, [$sizer, $content])
55+
}
56+
})
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { mount } from '@vue/test-utils'
2+
import { BAspect } from './aspect'
3+
4+
describe('aspect', () => {
5+
it('should have expected default structure', async () => {
6+
const wrapper = mount(BAspect)
7+
expect(wrapper.isVueInstance()).toBe(true)
8+
expect(wrapper.is('div')).toBe(true)
9+
expect(wrapper.classes()).toContain('b-aspect')
10+
expect(wrapper.classes()).toContain('d-flex')
11+
expect(wrapper.classes().length).toBe(2)
12+
13+
const $sizer = wrapper.find('.b-aspect-sizer')
14+
expect($sizer.exists()).toBe(true)
15+
expect($sizer.is('div')).toBe(true)
16+
expect($sizer.classes()).toContain('flex-grow-1')
17+
// Default aspect ratio is 1:1
18+
expect($sizer.attributes('style')).toContain('padding-bottom: 100%;')
19+
20+
const $content = wrapper.find('.b-aspect-content')
21+
expect($content.exists()).toBe(true)
22+
expect($content.is('div')).toBe(true)
23+
expect($content.classes()).toContain('flex-grow-1')
24+
expect($content.classes()).toContain('w-100')
25+
expect($content.classes()).toContain('mw-100')
26+
expect($content.attributes('style')).toContain('margin-left: -100%;')
27+
28+
wrapper.destroy()
29+
})
30+
31+
it('should have expected structure when prop `tag` is set', async () => {
32+
const wrapper = mount(BAspect, {
33+
propsData: {
34+
tag: 'section'
35+
}
36+
})
37+
expect(wrapper.isVueInstance()).toBe(true)
38+
expect(wrapper.is('section')).toBe(true)
39+
expect(wrapper.classes()).toContain('b-aspect')
40+
expect(wrapper.classes()).toContain('d-flex')
41+
expect(wrapper.classes().length).toBe(2)
42+
43+
const $sizer = wrapper.find('.b-aspect-sizer')
44+
expect($sizer.exists()).toBe(true)
45+
expect($sizer.is('div')).toBe(true)
46+
expect($sizer.classes()).toContain('flex-grow-1')
47+
// Default aspect ratio is 1:1
48+
expect($sizer.attributes('style')).toContain('padding-bottom: 100%;')
49+
50+
const $content = wrapper.find('.b-aspect-content')
51+
expect($content.exists()).toBe(true)
52+
expect($content.is('div')).toBe(true)
53+
expect($content.classes()).toContain('flex-grow-1')
54+
expect($content.classes()).toContain('w-100')
55+
expect($content.classes()).toContain('mw-100')
56+
expect($content.attributes('style')).toContain('margin-left: -100%;')
57+
58+
wrapper.destroy()
59+
})
60+
61+
it('should have expected structure when aspect is set to "4:3"', async () => {
62+
const wrapper = mount(BAspect, {
63+
propsData: {
64+
aspect: '4:3'
65+
}
66+
})
67+
expect(wrapper.isVueInstance()).toBe(true)
68+
expect(wrapper.is('div')).toBe(true)
69+
expect(wrapper.classes()).toContain('b-aspect')
70+
expect(wrapper.classes()).toContain('d-flex')
71+
expect(wrapper.classes().length).toBe(2)
72+
73+
const $sizer = wrapper.find('.b-aspect-sizer')
74+
expect($sizer.exists()).toBe(true)
75+
expect($sizer.is('div')).toBe(true)
76+
expect($sizer.classes()).toContain('flex-grow-1')
77+
expect($sizer.attributes('style')).toContain('padding-bottom: 75%;')
78+
79+
const $content = wrapper.find('.b-aspect-content')
80+
expect($content.exists()).toBe(true)
81+
expect($content.is('div')).toBe(true)
82+
expect($content.classes()).toContain('flex-grow-1')
83+
expect($content.classes()).toContain('w-100')
84+
expect($content.classes()).toContain('mw-100')
85+
expect($content.attributes('style')).toContain('margin-left: -100%;')
86+
87+
wrapper.destroy()
88+
})
89+
it('should have expected structure when aspect is set to `16/9`', async () => {
90+
const wrapper = mount(BAspect, {
91+
propsData: {
92+
aspect: 16 / 9
93+
}
94+
})
95+
expect(wrapper.isVueInstance()).toBe(true)
96+
expect(wrapper.is('div')).toBe(true)
97+
expect(wrapper.classes()).toContain('b-aspect')
98+
expect(wrapper.classes()).toContain('d-flex')
99+
expect(wrapper.classes().length).toBe(2)
100+
101+
const $sizer = wrapper.find('.b-aspect-sizer')
102+
expect($sizer.exists()).toBe(true)
103+
expect($sizer.is('div')).toBe(true)
104+
expect($sizer.classes()).toContain('flex-grow-1')
105+
expect($sizer.attributes('style')).toContain('padding-bottom: 56.25%;')
106+
107+
const $content = wrapper.find('.b-aspect-content')
108+
expect($content.exists()).toBe(true)
109+
expect($content.is('div')).toBe(true)
110+
expect($content.classes()).toContain('flex-grow-1')
111+
expect($content.classes()).toContain('w-100')
112+
expect($content.classes()).toContain('mw-100')
113+
expect($content.attributes('style')).toContain('margin-left: -100%;')
114+
115+
wrapper.destroy()
116+
})
117+
})

src/components/aspect/index.d.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
//
2+
// Aspect
3+
//
4+
import Vue from 'vue'
5+
import { BvPlugin, BvComponent } from '../../'
6+
7+
// Plugin
8+
export declare const AspectPlugin: BvPlugin
9+
10+
// Component: b-aspect
11+
export declare class BAspect extends BvComponent {}

src/components/aspect/index.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { BAspect } from './aspect'
2+
import { pluginFactory } from '../../utils/plugins'
3+
4+
const AspectPlugin = /*#__PURE__*/ pluginFactory({
5+
components: { BAspect }
6+
})
7+
8+
export { AspectPlugin, BAspect }

src/components/aspect/package.json

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"name": "@bootstrap-vue/aspect",
3+
"version": "1.0.0",
4+
"meta": {
5+
"title": "Aspect",
6+
"new": true,
7+
"version": "2.9.0",
8+
"description": "The `<b-aspect>` component can be used to maintain a minimum responsive aspect ratio for content.",
9+
"components": [
10+
{
11+
"component": "BAspect",
12+
"props": [
13+
{
14+
"prop": "aspect",
15+
"description": "Aspect as a width to height numeric ratio (such as `1.5`) or `width:height` string (such as '16:9')"
16+
}
17+
]
18+
}
19+
]
20+
}
21+
}

src/components/embed/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,4 +56,8 @@ embedded element. Note that the type `iframe` does not support any children.
5656
</div>
5757
```
5858

59+
## See also
60+
61+
- [`<b-aspect>` component](/docs/components/aspect)
62+
5963
<!-- Component reference added automatically from component package.json -->

src/components/form-spinbutton/README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@
44
> incrementing or decrementing a numerical value within a range of a minimum and maximum number,
55
> with optional step value.
66
7-
`<b-form-spinbutton>` is
7+
## Overview
8+
9+
`<b-form-spinbutton>` was introduced in BootstrapVue `v2.5.0`.
10+
11+
The component `<b-form-spinbutton>` is
812
[WAI-ARIA compliant](https://www.w3.org/TR/wai-aria-practices-1.2/#spinbutton), allowing for
913
[keyboard control](#accessibility), and supports both horizontal (default) and vertical layout.
1014

@@ -33,8 +37,6 @@ Similar to [range type inputs](/docs/components/form-input#range-type-input), Bo
3337
<!-- b-form-spinbutton-demo.vue -->
3438
```
3539

36-
## Overview
37-
3840
The <kbd>ArrowUp</kbd> and <kbd>ArrowDown</kbd> keys can be used to increment or decrement the
3941
value.
4042

src/components/form-spinbutton/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
"version": "1.0.0",
44
"meta": {
55
"title": "Form Spinbutton",
6-
"new": true,
76
"version": "2.5.0",
87
"description": "BootstrapVue custom numerical spinbutton form input component, featuring WAI-ARIA accessibility (a11y) and internationalization (i18n)",
98
"components": [

src/components/index.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export declare const componentsPlugin: BvPlugin
55

66
// Export all components as named exports
77
export * from './alert'
8+
export * from './aspect'
89
export * from './avatar'
910
export * from './badge'
1011
export * from './breadcrumb'

0 commit comments

Comments
 (0)