diff --git a/docs/assets/scss/styles.scss b/docs/assets/scss/styles.scss index 6385178955f..3506c1a257c 100644 --- a/docs/assets/scss/styles.scss +++ b/docs/assets/scss/styles.scss @@ -270,6 +270,11 @@ table#table-transition-example { } } +// `` overrides for docs +.b-sidebar { + z-index: 1071; +} + // Docsearch overrides // See: https://github.com/twbs/bootstrap/blob/master/site/static/docs/4.3/assets/scss/_algolia.scss .algolia-autocomplete { diff --git a/src/_variables.scss b/src/_variables.scss index a70921b26dc..1d1e64ffc17 100644 --- a/src/_variables.scss +++ b/src/_variables.scss @@ -71,6 +71,15 @@ $b-icon-animation-spin-reverse-pulse-duration: $b-icon-animation-spin-pulse-dura $b-icon-animation-cylon-duration: 0.75s !default; $b-icon-animation-cylon-vertical-duration: $b-icon-animation-cylon-duration !default; +// --- Sidebar --- + +$b-sidebar-width: 320px !default; +$b-sidebar-transition-duration: 0.3s !default; +$b-sidebar-zindex: calc(#{$zindex-fixed} + 5) !default; +$b-sidebar-header-font-size: 1.5rem !default; +$b-sidebar-header-padding-y: $navbar-padding-y !default; +$b-sidebar-header-padding-x: $navbar-padding-x !default; + // --- Tables --- // Table busy state diff --git a/src/components/form-timepicker/form-timepicker.js b/src/components/form-timepicker/form-timepicker.js index 839580eabc0..815f8526aa0 100644 --- a/src/components/form-timepicker/form-timepicker.js +++ b/src/components/form-timepicker/form-timepicker.js @@ -249,7 +249,7 @@ export const BFormTimepicker = /*#__PURE__*/ Vue.extend({ this.localHMS = newVal || '' }, localHMS(newVal) { - // We only update hte v-model value when the timepicker + // We only update the v-model value when the timepicker // is open, to prevent cursor jumps when bound to a // text input in button only mode if (this.isVisible) { diff --git a/src/components/index.d.ts b/src/components/index.d.ts index 03a1fb68fa8..496871ed5a5 100644 --- a/src/components/index.d.ts +++ b/src/components/index.d.ts @@ -45,6 +45,7 @@ export * from './pagination' export * from './pagination-nav' export * from './popover' export * from './progress' +export * from './sidebar' export * from './spinner' export * from './table' export * from './tabs' diff --git a/src/components/index.js b/src/components/index.js index 74e47073bb6..9a89b439804 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -42,6 +42,7 @@ import { PaginationPlugin } from './pagination' import { PaginationNavPlugin } from './pagination-nav' import { PopoverPlugin } from './popover' import { ProgressPlugin } from './progress' +import { SidebarPlugin } from './sidebar' import { SpinnerPlugin } from './spinner' // Table plugin includes TableLitePlugin and TableSimplePlugin import { TablePlugin } from './table' @@ -94,6 +95,7 @@ export const componentsPlugin = /*#__PURE__*/ pluginFactory({ PaginationNavPlugin, PopoverPlugin, ProgressPlugin, + SidebarPlugin, SpinnerPlugin, TablePlugin, TabsPlugin, diff --git a/src/components/index.scss b/src/components/index.scss index df6df675e66..b5aab3c72a6 100644 --- a/src/components/index.scss +++ b/src/components/index.scss @@ -15,6 +15,7 @@ @import "pagination/index"; @import "pagination-nav/index"; @import "popover/index"; +@import "sidebar/index"; @import "table/index"; @import "time/index"; @import "toast/index"; diff --git a/src/components/modal/modal.js b/src/components/modal/modal.js index bc2f9702167..b3e8a143b34 100644 --- a/src/components/modal/modal.js +++ b/src/components/modal/modal.js @@ -189,18 +189,22 @@ export const props = { type: [String, Array, Object], default: null }, + // TODO: Rename to `noHeader` and deprecate `hideHeader` hideHeader: { type: Boolean, default: false }, + // TODO: Rename to `noFooter` and deprecate `hideFooter` hideFooter: { type: Boolean, default: false }, + // TODO: Rename to `noHeaderClose` and deprecate `hideHeaderClose` hideHeaderClose: { type: Boolean, default: false }, + // TODO: Rename to `noBackdrop` and deprecate `hideBackdrop` hideBackdrop: { type: Boolean, default: false @@ -835,6 +839,7 @@ export const BModal = /*#__PURE__*/ Vue.extend({ // Modal header let header = h() if (!this.hideHeader) { + // TODO: Rename slot to `header` and deprecate `modal-header` let modalHeader = this.normalizeSlot('modal-header', this.slotScope) if (!modalHeader) { let closeButton = h() @@ -851,10 +856,12 @@ export const BModal = /*#__PURE__*/ Vue.extend({ }, on: { click: this.onClose } }, + // TODO: Rename slot to `header-close` and deprecate `modal-header-close` [this.normalizeSlot('modal-header-close')] ) } const domProps = + // TODO: Rename slot to `title` and deprecate `modal-title` !this.hasNormalizedSlot('modal-title') && this.titleHtml ? { innerHTML: this.titleHtml } : {} @@ -867,6 +874,7 @@ export const BModal = /*#__PURE__*/ Vue.extend({ attrs: { id: this.safeId('__BV_modal_title_') }, domProps }, + // TODO: Rename slot to `title` and deprecate `modal-title` [this.normalizeSlot('modal-title', this.slotScope) || stripTags(this.title)] ), closeButton @@ -899,6 +907,7 @@ export const BModal = /*#__PURE__*/ Vue.extend({ // Modal footer let footer = h() if (!this.hideFooter) { + // TODO: Rename slot to `footer` and deprecate `modal-footer` let modalFooter = this.normalizeSlot('modal-footer', this.slotScope) if (!modalFooter) { let cancelButton = h() @@ -916,6 +925,7 @@ export const BModal = /*#__PURE__*/ Vue.extend({ on: { click: this.onCancel } }, [ + // TODO: Rename slot to `cancel-button` and deprecate `modal-cancel` this.normalizeSlot('modal-cancel') || (cancelHtml ? h('span', { domProps: cancelHtml }) : stripTags(this.cancelTitle)) ] @@ -934,6 +944,7 @@ export const BModal = /*#__PURE__*/ Vue.extend({ on: { click: this.onOk } }, [ + // TODO: Rename slot to `ok-button` and deprecate `modal-ok` this.normalizeSlot('modal-ok') || (okHtml ? h('span', { domProps: okHtml }) : stripTags(this.okTitle)) ] @@ -1009,6 +1020,7 @@ export const BModal = /*#__PURE__*/ Vue.extend({ 'aria-labelledby': this.hideHeader || this.ariaLabel || + // TODO: Rename slot to `title` and deprecate `modal-title` !(this.hasNormalizedSlot('modal-title') || this.titleHtml || this.title) ? null : this.safeId('__BV_modal_title_'), @@ -1052,6 +1064,7 @@ export const BModal = /*#__PURE__*/ Vue.extend({ backdrop = h( 'div', { staticClass: 'modal-backdrop', attrs: { id: this.safeId('__BV_modal_backdrop_') } }, + // TODO: Rename slot to `backdrop` and deprecate `modal-backdrop` [this.normalizeSlot('modal-backdrop')] ) } diff --git a/src/components/sidebar/README.md b/src/components/sidebar/README.md new file mode 100644 index 00000000000..5e19b97e1b2 --- /dev/null +++ b/src/components/sidebar/README.md @@ -0,0 +1,309 @@ +# Sidebar + +> Otherwise known as off-canvas or a side drawer, BootstrapVue's custom `` component is a +> fixed-position toggleable slide out box, which can be used for navigation, menus, details, etc. It +> can be positioned on either the left (default) or right of the viewport. + +## Overview + +You can place almost any content inside the `` +[optionally scoped default slot](#scoped-default-slot), such as text, buttons, forms, images, or +[vertical navs](/docs/components/nav#vertical-variation). + +The component supports a header and built in close button, of which you can optionally disable and +provide your own header (if needed), and can be easily toggled with our `v-b-toggle` directive. + +The component has minimal default styling, which provides you with great flexibility in laying out +the content of the sidebar. + +The `` component was introduced in BootstrapVue `v2.10.0`. + +```html + + + +``` + +If the content is taller than the available viewport height, vertical scrolling will automatically +be enabled via CSS on the body of the sidebar. + +## Styling + +Several props are provided for controlling the appearance of the sidebar. + +### Title + +Sidebars should have a title (specifically for accessibility reasons). Easily set the title that +appears in the header either via the `title` prop or the `title` slot. Note the `title` slot takes +precedence over the `title` prop. + +If the [`no-header` prop](#hiding-the-header) is set, then neither the `title` prop or `title` slot +have any effect. + +If you do not provide a title, use either the `aria-label` or `aria-labelledby` props to provide an +accessible title for the sidebar. See the [Accessibility section](#accessibility) below for +additional details. + +### Placement + +By default the sidebar will be placed on the left side fo the viewport. Set the `right` prop to +`true` to have the sidebar appear on the right side of the viewport. + +```html + + + +``` + +### Variants + +Use the props `bg-variant` and `text-variant` to control the theme color variant of the background +and text, respectively. Alternatively, you can apply styles or classes to specify the background and +text colors. + +```html + + + +``` + +The standard Bootstrap theme variants are `'white'`, `'light'`, `'dark'`, `'primary'`, +`'secondary'`, `'success'`, `'danger'`, `'warning'`, and `'info'`. + +The default background variant is `'light'` and the default text variant is `'dark'`. + +### Shadow + +Prefer a sidebar with a backdrop shadow? Set the `shadow` prop to either boolean `true` for a medium +shadow, `'sm'` for a small shadow, or `'lg'` for a larger shadow. Set it to `false` (the default) +for no shadow. + +### Borders + +By default, `` has no borders. Use +[border utility classes](/docs/reference/utility-classes) to add border(s) to ``, or use +CSS style overrides. + +### Width + +By default the width of `` is restricted to `320px` (100% on 'xs' screens). Simply +provide a style of `width` to change the width to a preferred value. The max width is set to `100%`. + +### Padding + +The sidebar by default has no padding. You can apply padding utility classes to the component, or +margin/padding utility classes to the content of the sidebar. + +### Disable slide transition + +By default the sidebar will use a sliding transition when showing and hiding. You can disable the +slide transition via the `no-slide` prop. + +**Note:** The BootstrapVue defined transition effect of this component is dependent on the +`prefers-reduced-motion` media query. See the +[reduced motion section of our accessibility documentation](/docs/reference/accessibility) for +additional details. + +### Z-index + +The sidebar has a default `z-index` defined in SCSS/CSS. In some situations you may need to use a +different `z-index` to ensure the sidebar appears over or under other content. You can do so either +via CSS styles, or via the `z-index` prop. + +### Scoped default slot + +The `default` slot allows you to provide the body content for your sidebar. It is optionally scoped. +The examples in the following sections demonstrate the use of the default slot scope + +You can apply arbitrary classes to the body section via the `body-class` prop. + +### Header + +By default, `` has a header with optional title and a close button. You can supply a +title via the `title` prop, or via the optionally scoped slot `title`. + +You can apply arbitrary classes to the header section via the `header-class` prop, to override the +default padding, etc. + +#### Hiding the default header + +You can disable the default header (including the close button) via the `no-header` prop. Note that +you will need to provide a method of closing the sidebar. The `default` slot is scoped, which +includes a `hide()` method that can be used to close the sidebar. + +```html + + + +``` + +### Footer + +`` provides a `footer` slot (optionally scoped), to allow you to provide content that +appears at the bottom of the sidebar. The `footer` slot is scoped, which includes a `hide()` method +that can be used to close the sidebar. + +```html + + + +``` + +You can apply arbitrary classes to the footer section via the `footer-class` prop. + +### Lazy rendering + +In some instances, you may not want the content rendered when the sidebar is not visible. Simply set +the `lazy` prop on ``. When `lazy` is `true`, the body and optional footer will _not_ be +rendered (removed from DOM) whenever the sidebar is closed. + +## Visibility control + +### `v-b-toggle` directive + +Using the `v-b-toggle` directive is the prefered method for _opening_ the sidebar, as it +automatically handles applying the `aria-controls` and `aria-expanded` accessibility attributes on +the trigger element. + +The majority of examples on this page use the `v-b-toggle` directive. + +### `v-model` + +The `v-model` reflects the current visibility state of the sidebar. While it can be used to control +the visibility state of the sidebar, it is recommended to use the +[`v-b-toggle` directive](#v-b-toggle-directive) to _show_ the sidebar for accessibility reasons. If +you do use the `v-model` to show the sidebar, you should place the `aria-controls="id"` attribute +(where `id` is the ID of the sidebar) on the trigger element, and also set the `aria-expanded` +attribute (also on the trigger element) to either the string `'true'` (if the sidebar is open) or +`'false`' (if the sidebar is closed). + +The `v-model` is internally bound to the `visible` prop, and the `change` event updates the +`v-model`. + +### Closing on $route change + +By default, `` will close itself when the `$route` changes (full path including query and +hash). This can be particularly handy if the sidebar is placed outside of your `` and +is used for navigation. + +You can disable this behaviour by setting the `no-close-on-route-change` prop to `true`. + +## Events + +The sidebar will emit the `shown` event once the sidebar has opened, and the `hidden` event when the +sidebar has closed. + +The `change` event is used to update the `v-model` and is emitted whenever the visibility state of +the sidebar changes. + +## Accessibility + +`` provides several accessibility features. + +When the sidebar is opened, the entire sidebar will receive focus, which is desirable for screen +reader and keyboard-only users. When the sidebar is closed, the element that previously had focus +before the sidebar was opened will be re-focused. + +When the sidebar is open, users can press ESC to close the sidebar. To disable this +feature, set the `no-close-on-esc` prop to `true`. + +When you have hidden the header, or do not have a title for the sidebar, set either `aria-label` to +a string that describes the sidebar, or set `aria-labelledby` to an ID of an element that contains +the title. When using the `lazy` prop _and_ you do not have a header, use the `aria-label` prop to +provide an appropriate string to label the sidebar. + +## Implementation notes + +BootstrapVue's custom SCSS/CSS is required for proper styling, and positioning of the sidebar. + +The Bootstrap v4 background (`'bg-*'`) and text (`'text-*'`) utility classes are used for +controlling the background and font color, respectively. + +Some of the default styling for `` can be customized via the use of SASS variables. Refer +to the [theming documentation](/docs/reference/theming) for additional details. + +## See also + +- [`` component](/docs/components/collapse) +- [`` component](/docs/components/button#comp-ref-b-button-close) diff --git a/src/components/sidebar/_sidebar.scss b/src/components/sidebar/_sidebar.scss new file mode 100644 index 00000000000..346fb535811 --- /dev/null +++ b/src/components/sidebar/_sidebar.scss @@ -0,0 +1,78 @@ +.b-sidebar { + display: flex; + flex-direction: column; + position: fixed !important; + top: 0; + bottom: 0; + width: $b-sidebar-width; + max-width: 100% !important; + height: 100vh !important; + margin: 0 !important; + outline: 0; + transform: translateX(0); + z-index: $b-sidebar-zindex; + + &.slide { + transition: transform $b-sidebar-transition-duration ease-in-out; + @media (prefers-reduced-motion: reduce) { + transition: none; + } + } + + &:not(.b-sidebar-right) { + left: 0; + right: auto; + + &.slide:not(.show) { + transform: translateX(-100%); + } + + > .b-sidebar-header .close { + margin-left: auto; + } + } + + &.b-sidebar-right { + left: auto; + right: 0; + + &.slide:not(.show) { + transform: translateX(100%); + } + + > .b-sidebar-header .close { + margin-right: auto; + } + } + + > .b-sidebar-header { + font-size: $b-sidebar-header-font-size; + padding: $b-sidebar-header-padding-y $b-sidebar-header-padding-x; + display: flex; + flex-direction: row; + flex-grow: 0; + align-items: center; + + @at-root { + // Keep the buttons on the correct end when in RTL mode + [dir="rtl"] & { + flex-direction: row-reverse; + } + } + + .close { + float: none; + font-size: $b-sidebar-header-font-size; + } + } + + > .b-sidebar-body { + flex-grow: 1; + height: 100%; + overflow-y: auto; + } + + > .b-sidebar-footer { + flex-grow: 0; + } +} diff --git a/src/components/sidebar/index.d.ts b/src/components/sidebar/index.d.ts new file mode 100644 index 00000000000..2b66de90936 --- /dev/null +++ b/src/components/sidebar/index.d.ts @@ -0,0 +1,11 @@ +// +// Sidebar +// +import Vue from 'vue' +import { BvPlugin, BvComponent } from '../../' + +// Plugin +export declare const SidebarPlugin: BvPlugin + +// Component: b-sidebar +export declare class BSidebar extends BvComponent {} diff --git a/src/components/sidebar/index.js b/src/components/sidebar/index.js new file mode 100644 index 00000000000..d777a6a62f2 --- /dev/null +++ b/src/components/sidebar/index.js @@ -0,0 +1,10 @@ +import { BSidebar } from './sidebar' +import { VBTogglePlugin } from '../../directives/toggle' +import { pluginFactory } from '../../utils/plugins' + +const SidebarPlugin = /*#__PURE__*/ pluginFactory({ + components: { BSidebar }, + plugins: { VBTogglePlugin } +}) + +export { SidebarPlugin, BSidebar } diff --git a/src/components/sidebar/index.scss b/src/components/sidebar/index.scss new file mode 100644 index 00000000000..75f97451361 --- /dev/null +++ b/src/components/sidebar/index.scss @@ -0,0 +1 @@ +@import "sidebar"; diff --git a/src/components/sidebar/package.json b/src/components/sidebar/package.json new file mode 100644 index 00000000000..ddc53534f8c --- /dev/null +++ b/src/components/sidebar/package.json @@ -0,0 +1,183 @@ +{ + "name": "@bootstrap-vue/sidebar", + "version": "1.0.0", + "meta": { + "title": "Sidebar", + "new": true, + "version": "2.10.0", + "description": "The `` component creates a fixed viewport, left or right, sliding popout drawer.", + "plugins": [ + "VBTogglePlugin" + ], + "components": [ + { + "component": "BSidebar", + "version": "2.10.0", + "props": [ + { + "prop": "title", + "description": "Text content to place in the default header. The `title` slot takes precedence" + }, + { + "prop": "right", + "description": "When `true`, positions the sidebar on the right of the viewport" + }, + { + "prop": "visible", + "description": "When `true`, opens the sidebar. This is the `v-model`" + }, + { + "prop": "bgVariant", + "description": "Theme variant color for the background of the sidebar" + }, + { + "prop": "textVariant", + "description": "Theme variant color for the text of the sidebar" + }, + { + "prop": "noSlide", + "description": "When set, disables the default sliding animation" + }, + { + "prop": "shadow", + "description": "Set to boolean `true` for medium shadow, 'sm' for small shadow, 'lg' for large shadow, or boolean `false` for no shadow. Default is no shadow" + }, + { + "prop": "width", + "description": "CSS width for the sidebar. Defaults to '320px' as defined by SCSS/CSS" + }, + { + "prop": "zIndex", + "description": "Specify an arbitrary z-index value to override the value defined by SCSS/CSS" + }, + { + "prop": "closeLabel", + "description": "`aria-label` to apply to the built-in close button. Defaults to 'Close'" + }, + { + "prop": "headerClass", + "description": "Class, or classes, to apply to the built in header. Has no effect if prop `no-header` is set" + }, + { + "prop": "bodyClass", + "description": "Class, or classes, to apply to the body (default slot) of the sidebar" + }, + { + "prop": "footerClass", + "description": "Class, or classes, to apply to the optional `footer` slot" + }, + { + "prop": "lazy", + "description": "When set to `true`, the content of the sidebar will only be rendered while the sidebar is open" + }, + { + "prop": "noHeader", + "description": "When set to `true` disables rendering of the default header (including close button)" + }, + { + "prop": "noHeaderClose", + "description": "When set to `true` disables rendering of the header close button" + }, + { + "prop": "noCloseOnEsc", + "description": "When set to `true`, disables closing the sidebar when the user presses ESC" + }, + { + "prop": "noCloseOnRouteChange", + "description": "When set to `true`, disables closing of the sidebar on route change" + } + ], + "events": [ + { + "event": "change", + "description": "Emitted whenever the visibility of the sidebar changes. Used to update the `v-model`", + "args": [ + { + "arg": "visible", + "type": "Boolean", + "description": "`true` if the sidebar is open, `false` if it is closed (or in the process of closing)" + } + ] + }, + { + "event": "shown", + "description": "Emitted when the sidebar has opened" + }, + { + "event": "hidden", + "description": "Emitted when the sidebar has been hidden" + } + ], + "slots": [ + { + "name": "title", + "description": "Content to place in the title of the built-in header. Takes precedence over the `title` prop", + "scope": [ + { + "prop": "hide", + "type": "Function", + "description": "When called, will close the sidebar" + }, + { + "prop": "visible", + "type": "Boolean", + "description": "`true` if the sidebar is open" + }, + { + "prop": "right", + "type": "Boolean", + "description": "`true` if the sidebar is on the right" + } + ] + }, + { + "name": "header-close", + "description": "Content of the header close button. Defaults to ``" + }, + { + "name": "default", + "description": "Content to place in the body of the sidebar", + "scope": [ + { + "prop": "hide", + "type": "Function", + "description": "When called, will close the sidebar" + }, + { + "prop": "visible", + "type": "Boolean", + "description": "`true` if the sidebar is open" + }, + { + "prop": "right", + "type": "Boolean", + "description": "`true` if the sidebar is on the right" + } + ] + }, + { + "name": "footer", + "description": "Content to place in the optional footer", + "scope": [ + { + "prop": "hide", + "type": "Function", + "description": "When called, will close the sidebar" + }, + { + "prop": "visible", + "type": "Boolean", + "description": "`true` if the sidebar is open" + }, + { + "prop": "right", + "type": "Boolean", + "description": "`true` if the sidebar is on the right" + } + ] + } + ] + } + ] + } +} diff --git a/src/components/sidebar/sidebar.js b/src/components/sidebar/sidebar.js new file mode 100644 index 00000000000..75b699b1a0c --- /dev/null +++ b/src/components/sidebar/sidebar.js @@ -0,0 +1,381 @@ +import Vue from '../../utils/vue' +import KeyCodes from '../../utils/key-codes' +import { contains } from '../../utils/dom' +import { getComponentConfig } from '../../utils/config' +import { toString } from '../../utils/string' +import idMixin from '../../mixins/id' +import listenOnRootMixin from '../../mixins/listen-on-root' +import normalizeSlotMixin from '../../mixins/normalize-slot' +import { + EVENT_TOGGLE, + EVENT_STATE, + EVENT_STATE_REQUEST, + EVENT_STATE_SYNC +} from '../../directives/toggle/toggle' +import { BButtonClose } from '../button/button-close' +import { BIconX } from '../../icons/icons' + +// --- Constants --- + +const NAME = 'BSidebar' +const CLASS_NAME = 'b-sidebar' + +// --- Render methods --- +const renderHeaderTitle = (h, ctx) => { + const title = ctx.normalizeSlot('title', ctx.slotScope) || toString(ctx.title) || null + + // Render a empty `` when to title was provided + if (!title) { + return h('span') + } + + return h('strong', { attrs: { id: ctx.safeId('__title__') } }, [title]) +} + +const renderHeaderClose = (h, ctx) => { + if (ctx.noHeaderClose) { + return h() + } + + const { closeLabel, textVariant, hide } = ctx + + return h( + BButtonClose, + { + ref: 'close-button', + props: { ariaLabel: closeLabel, textVariant }, + on: { click: hide } + }, + [ctx.normalizeSlot('header-close') || h(BIconX)] + ) +} + +const renderHeader = (h, ctx) => { + if (ctx.noHeader) { + return h() + } + + const $title = renderHeaderTitle(h, ctx) + const $close = renderHeaderClose(h, ctx) + + return h( + 'header', + { + key: 'header', + staticClass: `${CLASS_NAME}-header`, + class: ctx.headerClass + }, + ctx.right ? [$close, $title] : [$title, $close] + ) +} + +const renderBody = (h, ctx) => { + return h( + 'div', + { + key: 'body', + staticClass: `${CLASS_NAME}-body`, + class: ctx.bodyClass + }, + [ctx.normalizeSlot('default', ctx.slotScope)] + ) +} + +const renderFooter = (h, ctx) => { + const $footer = ctx.normalizeSlot('footer', ctx.slotScope) + if (!$footer) { + return h() + } + + return h( + 'footer', + { + key: 'footer', + staticClass: `${CLASS_NAME}-footer`, + class: ctx.footerClass + }, + [$footer] + ) +} + +const renderContent = (h, ctx) => { + // We render the header even if `lazy` is enabled as it + // acts as the accessible label for the sidebar + const $header = renderHeader(h, ctx) + if (ctx.lazy && !ctx.isOpen) { + return $header + } + return [$header, renderBody(h, ctx), renderFooter(h, ctx)] +} + +// --- Main component --- +// @vue/component +export const BSidebar = /*#__PURE__*/ Vue.extend({ + name: NAME, + mixins: [idMixin, listenOnRootMixin, normalizeSlotMixin], + model: { + prop: 'visible', + event: 'change' + }, + props: { + title: { + type: String + // default: null + }, + right: { + type: Boolean, + default: false + }, + bgVariant: { + type: String, + default: () => getComponentConfig(NAME, 'bgVariant') + }, + textVariant: { + type: String, + default: () => getComponentConfig(NAME, 'textVariant') + }, + shadow: { + type: [Boolean, String], + default: () => getComponentConfig(NAME, 'shadow') + }, + width: { + type: String, + default: () => getComponentConfig(NAME, 'width') + }, + zIndex: { + type: [Number, String] + // default: null + }, + ariaLabel: { + type: String + // default: null + }, + ariaLabelledby: { + type: String + // default: null + }, + closeLabel: { + // `aria-label` for close button + // Defaults to 'Close' + type: String + // default: undefined + }, + tag: { + type: String, + default: () => getComponentConfig(NAME, 'tag') + }, + headerClass: { + type: [String, Array, Object] + // default: null + }, + bodyClass: { + type: [String, Array, Object] + // default: null + }, + footerClass: { + type: [String, Array, Object] + // default: null + }, + noSlide: { + type: Boolean, + default: false + }, + noHeader: { + type: Boolean, + default: false + }, + noHeaderClose: { + type: Boolean, + default: false + }, + noCloseOnEsc: { + type: Boolean, + default: false + }, + noCloseOnRouteChange: { + type: Boolean, + default: false + }, + lazy: { + type: Boolean, + default: false + }, + visible: { + type: Boolean, + default: false + } + }, + data() { + return { + // Internal `v-model` state + localShow: !!this.visible, + // For lazy render triggering + isOpen: !!this.visible + } + }, + computed: { + transitionProps() { + return this.noSlide + ? { css: true } + : { + css: true, + enterClass: '', + enterActiveClass: 'slide', + enterToClass: 'show', + leaveClass: 'show', + leaveActiveClass: 'slide', + leaveToClass: '' + } + }, + slotScope() { + return { + visible: this.localShow, + right: this.right, + hide: this.hide + } + } + }, + watch: { + visible(newVal, oldVal) { + if (newVal !== oldVal) { + this.localShow = newVal + } + }, + localShow(newVal, oldVal) { + if (newVal !== oldVal) { + this.emitState(newVal) + this.$emit('change', newVal) + } + }, + $route(newVal = {}, oldVal = {}) /* istanbul ignore next: pain to mock */ { + if (!this.noCloseOnRouteChange && newVal.fullPath !== oldVal.fullPath) { + this.hide() + } + } + }, + created() { + // Define non-reactive properties + this.$_returnFocusEl = null + }, + mounted() { + // Add `$root` listeners + this.listenOnRoot(EVENT_TOGGLE, this.handleToggle) + this.listenOnRoot(EVENT_STATE_REQUEST, this.handleSync) + // Send out a gratuitous state event to ensure toggle button is synced + this.$nextTick(() => { + this.emitState(this.localShow) + }) + }, + activated() /* istanbul ignore next */ { + this.emitSync() + }, + beforeDestroy() { + this.localShow = false + this.$_returnFocusEl = null + }, + methods: { + hide() { + this.localShow = false + }, + emitState(state = this.localShow) { + this.emitOnRoot(EVENT_STATE, this.safeId(), state) + }, + emitSync(state = this.localShow) { + this.emitOnRoot(EVENT_STATE_SYNC, this.safeId(), state) + }, + handleToggle(id) { + // Note `safeId()` can be null until after mount + if (id && id === this.safeId()) { + this.localShow = !this.localShow + } + }, + handleSync(id) { + // Note `safeId()` can be null until after mount + if (id && id === this.safeId()) { + this.$nextTick(() => { + this.emitSync(this.localShow) + }) + } + }, + onKeydown(evt) { + const { keyCode } = evt + if (!this.noCloseOnEsc && keyCode === KeyCodes.ESC) { + this.hide() + } + }, + onBeforeEnter() { + this.$_returnFocusEl = null + try { + this.$_returnFocusEl = document.activeElement || null + } catch {} + // Trigger lazy render + this.isOpen = true + }, + onAfterEnter(el) { + try { + if (!contains(el, document.activeElement)) { + el.focus() + } + } catch {} + this.$emit('shown') + }, + onAfterLeave() { + try { + this.$_returnFocusEl.focus() + } catch {} + this.$_returnFocusEl = null + // Trigger lazy render + this.isOpen = false + this.$emit('hidden') + } + }, + render(h) { + const localShow = this.localShow + const shadow = this.shadow === '' ? true : this.shadow + const title = this.normalizeSlot('title', this.slotScope) || toString(this.title) || null + const titleId = title ? this.safeId('__title__') : null + const ariaLabel = this.ariaLabel || null + // `ariaLabel` takes precedence over `ariaLabelledby` + const ariaLabelledby = this.ariaLabelledby || titleId || null + + const $sidebar = h( + this.tag, + { + directives: [{ name: 'show', value: localShow }], + staticClass: CLASS_NAME, + class: { + shadow: shadow === true, + [`shadow-${shadow}`]: shadow && shadow !== true, + [`${CLASS_NAME}-right`]: this.right, + [`bg-${this.bgVariant}`]: !!this.bgVariant, + [`text-${this.textVariant}`]: !!this.textVariant + }, + attrs: { + id: this.safeId(), + tabindex: '-1', + role: 'dialog', + 'aria-modal': 'false', + 'aria-hidden': localShow ? 'true' : null, + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledby + }, + style: { width: this.width, zIndex: this.zIndex }, + on: { keydown: this.onKeydown } + }, + [renderContent(h, this)] + ) + + return h( + 'transition', + { + props: this.transitionProps, + on: { + beforeEnter: this.onBeforeEnter, + afterEnter: this.onAfterEnter, + afterLeave: this.onAfterLeave + } + }, + [$sidebar] + ) + } +}) diff --git a/src/components/sidebar/sidebar.spec.js b/src/components/sidebar/sidebar.spec.js new file mode 100644 index 00000000000..48074b285ca --- /dev/null +++ b/src/components/sidebar/sidebar.spec.js @@ -0,0 +1,371 @@ +import { mount, createWrapper } from '@vue/test-utils' +import { waitNT, waitRAF } from '../../../tests/utils' +import { BSidebar } from './sidebar' + +const EVENT_TOGGLE = 'bv::toggle::collapse' +const EVENT_STATE = 'bv::collapse::state' +const EVENT_STATE_SYNC = 'bv::collapse::sync::state' +const EVENT_STATE_REQUEST = 'bv::request::collapse::state' + +describe('sidebar', () => { + it('should have expected default structure', async () => { + const wrapper = mount(BSidebar, { + attachToDocument: true, + propsData: { + id: 'test-1', + visible: true + }, + stubs: { + // Disable use of default test `transitionStub` component + transition: false + } + }) + expect(wrapper.isVueInstance()).toBe(true) + await waitNT(wrapper.vm) + await waitRAF() + await waitNT(wrapper.vm) + await waitRAF() + + expect(wrapper.is('div')).toBe(true) + expect(wrapper.attributes('id')).toBeDefined() + expect(wrapper.attributes('id')).toEqual('test-1') + expect(wrapper.classes()).toContain('b-sidebar') + expect(wrapper.classes()).not.toContain('b-sidebar-right') + // `show` and `slide` class only added during transition + expect(wrapper.classes()).not.toContain('show') + expect(wrapper.classes()).not.toContain('slide') + expect(wrapper.text()).toEqual('') + // Check for no presence of `display: none' from `v-show` directive + expect(wrapper.isVisible()).toBe(true) + + expect(wrapper.find('.b-sidebar-header').exists()).toBe(true) + expect(wrapper.find('.b-sidebar-body').exists()).toBe(true) + expect(wrapper.find('.b-sidebar-footer').exists()).toBe(false) + + wrapper.setProps({ + visible: false + }) + await waitNT(wrapper.vm) + await waitRAF() + await waitNT(wrapper.vm) + await waitRAF() + + expect(wrapper.isVueInstance()).toBe(true) + expect(wrapper.is('div')).toBe(true) + // Check for no presence of `display: none' from `v-show` directive + expect(wrapper.isVisible()).toBe(false) + + wrapper.setProps({ + visible: true + }) + await waitNT(wrapper.vm) + await waitRAF() + await waitNT(wrapper.vm) + await waitRAF() + + expect(wrapper.is('div')).toBe(true) + // Check for no presence of `display: none' from `v-show` directive + expect(wrapper.isVisible()).toBe(true) + + wrapper.destroy() + }) + + it('shows and hides in response to v-b-toggle events', async () => { + const wrapper = mount(BSidebar, { + attachToDocument: true, + propsData: { + id: 'test-toggle' + }, + stubs: { + // Disable use of default test `transitionStub` component + transition: false + } + }) + expect(wrapper.isVueInstance()).toBe(true) + await waitNT(wrapper.vm) + await waitRAF() + await waitNT(wrapper.vm) + await waitRAF() + + expect(wrapper.is('div')).toBe(true) + expect(wrapper.isVisible()).toBe(false) + + wrapper.vm.$root.$emit(EVENT_TOGGLE, 'test-toggle') + await waitNT(wrapper.vm) + await waitRAF() + await waitNT(wrapper.vm) + await waitRAF() + + expect(wrapper.is('div')).toBe(true) + expect(wrapper.isVisible()).toBe(true) + + wrapper.vm.$root.$emit(EVENT_TOGGLE, 'test-toggle') + await waitNT(wrapper.vm) + await waitRAF() + await waitNT(wrapper.vm) + await waitRAF() + + expect(wrapper.is('div')).toBe(true) + expect(wrapper.isVisible()).toBe(false) + + wrapper.vm.$root.$emit(EVENT_TOGGLE, 'foobar') + await waitNT(wrapper.vm) + await waitRAF() + await waitNT(wrapper.vm) + await waitRAF() + + expect(wrapper.is('div')).toBe(true) + expect(wrapper.isVisible()).toBe(false) + + wrapper.destroy() + }) + + it('closes when ESC key is pressed', async () => { + const wrapper = mount(BSidebar, { + attachToDocument: true, + propsData: { + id: 'test-esc' + }, + stubs: { + // Disable use of default test `transitionStub` component + transition: false + } + }) + expect(wrapper.isVueInstance()).toBe(true) + await waitNT(wrapper.vm) + await waitRAF() + await waitNT(wrapper.vm) + await waitRAF() + + expect(wrapper.is('div')).toBe(true) + expect(wrapper.isVisible()).toBe(false) + + wrapper.vm.$root.$emit(EVENT_TOGGLE, 'test-esc') + await waitNT(wrapper.vm) + await waitRAF() + await waitNT(wrapper.vm) + await waitRAF() + + expect(wrapper.is('div')).toBe(true) + expect(wrapper.isVisible()).toBe(true) + + wrapper.trigger('keydown.esc') + await waitNT(wrapper.vm) + await waitRAF() + await waitNT(wrapper.vm) + await waitRAF() + + expect(wrapper.is('div')).toBe(true) + expect(wrapper.isVisible()).toBe(false) + + wrapper.setProps({ + noCloseOnEsc: true + }) + wrapper.vm.$root.$emit(EVENT_TOGGLE, 'test-esc') + await waitNT(wrapper.vm) + await waitRAF() + await waitNT(wrapper.vm) + await waitRAF() + + expect(wrapper.is('div')).toBe(true) + expect(wrapper.isVisible()).toBe(true) + + wrapper.trigger('keydown.esc') + await waitNT(wrapper.vm) + await waitRAF() + await waitNT(wrapper.vm) + await waitRAF() + + expect(wrapper.is('div')).toBe(true) + expect(wrapper.isVisible()).toBe(true) + + wrapper.destroy() + }) + + it('handles state sync requests', async () => { + const wrapper = mount(BSidebar, { + attachToDocument: true, + propsData: { + id: 'test-sync', + visible: true + }, + stubs: { + // Disable use of default test `transitionStub` component + transition: false + } + }) + expect(wrapper.isVueInstance()).toBe(true) + const rootWrapper = createWrapper(wrapper.vm.$root) + await waitNT(wrapper.vm) + await waitRAF() + await waitNT(wrapper.vm) + await waitRAF() + expect(rootWrapper.emitted(EVENT_STATE)).toBeDefined() + expect(rootWrapper.emitted(EVENT_STATE).length).toBe(1) + expect(rootWrapper.emitted(EVENT_STATE)[0][0]).toBe('test-sync') // ID + expect(rootWrapper.emitted(EVENT_STATE)[0][1]).toBe(true) // Visible state + expect(rootWrapper.emitted(EVENT_STATE_SYNC)).not.toBeDefined() + + rootWrapper.vm.$root.$emit(EVENT_STATE_REQUEST, 'test-sync') + await waitNT(wrapper.vm) + await waitRAF() + expect(rootWrapper.emitted(EVENT_STATE_SYNC)).toBeDefined() + expect(rootWrapper.emitted(EVENT_STATE_SYNC).length).toBe(1) + expect(rootWrapper.emitted(EVENT_STATE_SYNC)[0][0]).toBe('test-sync') // ID + expect(rootWrapper.emitted(EVENT_STATE_SYNC)[0][1]).toBe(true) // Visible state + + wrapper.destroy() + }) + + it('should have expected structure when `no-header` is set', async () => { + const wrapper = mount(BSidebar, { + attachToDocument: true, + propsData: { + id: 'test-2', + visible: true, + noHeader: true + }, + stubs: { + // Disable use of default test `transitionStub` component + transition: false + } + }) + expect(wrapper.isVueInstance()).toBe(true) + await waitNT(wrapper.vm) + await waitRAF() + await waitNT(wrapper.vm) + await waitRAF() + + expect(wrapper.is('div')).toBe(true) + expect(wrapper.find('.b-sidebar-header').exists()).toBe(false) + expect(wrapper.find('.b-sidebar-body').exists()).toBe(true) + expect(wrapper.find('.b-sidebar-footer').exists()).toBe(false) + + wrapper.destroy() + }) + + it('should have expected structure when `no-header-close` is set', async () => { + const wrapper = mount(BSidebar, { + attachToDocument: true, + propsData: { + id: 'test-3', + visible: true, + noHeaderClose: true + }, + stubs: { + // Disable use of default test `transitionStub` component + transition: false + } + }) + expect(wrapper.isVueInstance()).toBe(true) + await waitNT(wrapper.vm) + await waitRAF() + await waitNT(wrapper.vm) + await waitRAF() + + expect(wrapper.is('div')).toBe(true) + expect(wrapper.find('.b-sidebar-header').exists()).toBe(true) + expect(wrapper.find('.b-sidebar-header .close').exists()).toBe(false) + expect(wrapper.find('.b-sidebar-body').exists()).toBe(true) + expect(wrapper.find('.b-sidebar-footer').exists()).toBe(false) + + wrapper.destroy() + }) + + it('should have expected structure when `lazy` is set', async () => { + const wrapper = mount(BSidebar, { + attachToDocument: true, + propsData: { + id: 'test-4', + visible: false, + lazy: true + }, + stubs: { + // Disable use of default test `transitionStub` component + transition: false + } + }) + expect(wrapper.isVueInstance()).toBe(true) + await waitNT(wrapper.vm) + await waitRAF() + await waitNT(wrapper.vm) + await waitRAF() + + expect(wrapper.is('div')).toBe(true) + expect(wrapper.find('.b-sidebar-header').exists()).toBe(true) + expect(wrapper.find('.b-sidebar-body').exists()).toBe(false) + expect(wrapper.find('.b-sidebar-footer').exists()).toBe(false) + + wrapper.setProps({ + visible: true + }) + await waitNT(wrapper.vm) + await waitRAF() + await waitNT(wrapper.vm) + await waitRAF() + expect(wrapper.is('div')).toBe(true) + expect(wrapper.find('.b-sidebar-header').exists()).toBe(true) + expect(wrapper.find('.b-sidebar-body').exists()).toBe(true) + expect(wrapper.find('.b-sidebar-footer').exists()).toBe(false) + + wrapper.destroy() + }) + + it('should have expected structure when `footer` slot provided', async () => { + const wrapper = mount(BSidebar, { + attachToDocument: true, + propsData: { + id: 'test-5', + visible: true + }, + slots: { + footer: 'FOOTER' + }, + stubs: { + // Disable use of default test `transitionStub` component + transition: false + } + }) + expect(wrapper.isVueInstance()).toBe(true) + await waitNT(wrapper.vm) + await waitRAF() + await waitNT(wrapper.vm) + await waitRAF() + + expect(wrapper.is('div')).toBe(true) + expect(wrapper.find('.b-sidebar-header').exists()).toBe(true) + expect(wrapper.find('.b-sidebar-body').exists()).toBe(true) + expect(wrapper.find('.b-sidebar-footer').exists()).toBe(true) + expect(wrapper.find('.b-sidebar-footer').text()).toEqual('FOOTER') + + wrapper.destroy() + }) + + it('should have expected structure when `title` prop provided', async () => { + const wrapper = mount(BSidebar, { + attachToDocument: true, + propsData: { + id: 'test-title', + visible: true, + title: 'TITLE' + }, + stubs: { + // Disable use of default test `transitionStub` component + transition: false + } + }) + expect(wrapper.isVueInstance()).toBe(true) + await waitNT(wrapper.vm) + await waitRAF() + await waitNT(wrapper.vm) + await waitRAF() + + expect(wrapper.is('div')).toBe(true) + expect(wrapper.find('.b-sidebar-header').exists()).toBe(true) + expect(wrapper.find('.b-sidebar-header > strong').text()).toEqual('TITLE') + expect(wrapper.find('.b-sidebar-body').exists()).toBe(true) + expect(wrapper.find('.b-sidebar-footer').exists()).toBe(false) + + wrapper.destroy() + }) +}) diff --git a/src/components/tooltip/helpers/bv-tooltip.js b/src/components/tooltip/helpers/bv-tooltip.js index e35f657a8f0..59707dcdbd8 100644 --- a/src/components/tooltip/helpers/bv-tooltip.js +++ b/src/components/tooltip/helpers/bv-tooltip.js @@ -43,6 +43,12 @@ const MODAL_SELECTOR = '.modal-content' // Modal `$root` hidden event const MODAL_CLOSE_EVENT = 'bv::modal::hidden' +// Sidebar container selector for appending tooltip/popover +const SIDEBAR_SELECTOR = '.b-sidebar' + +// For finding the container to append to +const CONTAINER_SELECTOR = [MODAL_SELECTOR, SIDEBAR_SELECTOR].join(', ') + // For dropdown sniffing const DROPDOWN_CLASS = 'dropdown' const DROPDOWN_OPEN_SELECTOR = '.dropdown-menu.show' @@ -511,14 +517,15 @@ export const BVTooltip = /*#__PURE__*/ Vue.extend({ const container = this.container ? this.container.$el || this.container : false const body = document.body const target = this.getTarget() - // If we are in a modal, we append to the modal instead - // of body, unless a container is specified + // If we are in a modal, we append to the modal, If we + // are in a sidebar, we append to the sidebar, else append + // to body, unless a container is specified // TODO: // Template should periodically check to see if it is in dom // And if not, self destruct (if container got v-if'ed out of DOM) // Or this could possibly be part of the visibility check return container === false - ? closest(MODAL_SELECTOR, target) || body + ? closest(CONTAINER_SELECTOR, target) || body : isString(container) ? getById(container.replace(/^#/, '')) || body : body diff --git a/src/index.js b/src/index.js index 80c4ce9eac1..cf2cea72020 100644 --- a/src/index.js +++ b/src/index.js @@ -274,6 +274,10 @@ export { ProgressPlugin } from './components/progress' export { BProgress } from './components/progress/progress' export { BProgressBar } from './components/progress/progress-bar' +// export * from './components/sidebar' +export { SidebarPlugin } from './components/sidebar' +export { BSidebar } from './components/sidebar/sidebar' + // export * from './components/spinner' export { SpinnerPlugin } from './components/spinner' export { BSpinner } from './components/spinner/spinner' diff --git a/src/utils/config-defaults.js b/src/utils/config-defaults.js index 378a02684d3..01a22956a02 100644 --- a/src/utils/config-defaults.js +++ b/src/utils/config-defaults.js @@ -69,7 +69,7 @@ export default deepFreeze({ }, BCalendar: { // BFormDate will choose these first if not provided in BFormDate section - labelPrevYear: 'Previous year', + labelPrevYear: 'Previous year', labelPrevMonth: 'Previous month', labelCurrentMonth: 'Current month', labelNextMonth: 'Next month', @@ -224,6 +224,13 @@ export default deepFreeze({ BSpinner: { variant: null }, + BSidebar: { + bgVariant: 'light', + textVariant: 'dark', + shadow: false, + width: null, + tag: 'div' + }, BTable: { selectedVariant: 'active', headVariant: null,