1
0
mirror of https://github.com/tabler/tabler.git synced 2026-04-12 11:33:07 +04:00

Remove Bootstrap dependency and vendor JS/SCSS sources into Tabler (#2627)

This commit is contained in:
Paweł Kuna
2026-03-18 21:55:42 +01:00
committed by GitHub
parent 65829e9d5e
commit 9d5c83f3ad
165 changed files with 25538 additions and 1026 deletions

View File

@@ -0,0 +1,5 @@
---
"@tabler/core": minor
---
Vendored Bootstrap 5.3.8 into core: migrated SCSS and JS to Tabler source tree, refactored mixins to module syntax, converted Bootstrap components to TypeScript.

View File

@@ -0,0 +1,5 @@
---
"@tabler/core": patch
---
Added support for `data-tblr-*` attributes alongside `data-bs-*` for dropdown and other components.

View File

@@ -0,0 +1,5 @@
---
"@tabler/core": patch
---
Added Vitest for unit tests and Playwright for visual tests of Bootstrap DOM and components.

37
.github/workflows/js-tests.yml vendored Normal file
View File

@@ -0,0 +1,37 @@
name: JS Tests
on:
pull_request: null
push:
branches:
- dev
- main
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Clone repository
uses: actions/checkout@v6
- name: Install PNPM
uses: pnpm/action-setup@v4
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install
- name: Install Playwright Chromium
run: pnpm exec playwright install --with-deps chromium
working-directory: core
- name: Run tests
run: pnpm --filter @tabler/core test

6
.gitignore vendored
View File

@@ -41,4 +41,8 @@ sri.json
# TypeScript
*.tsbuildinfo
.tsbuildinfo
.tsbuildinfo
# Test coverage
coverage/
__screenshots__/

View File

@@ -1,7 +1,42 @@
export * as Popper from '@popperjs/core'
// Export all Bootstrap components directly for consistent usage
export { Alert, Button, Carousel, Collapse, Dropdown, Modal, Offcanvas, Popover, ScrollSpy, Tab, Toast, Tooltip } from 'bootstrap'
export { default as Alert } from './bootstrap/alert'
export { default as Button } from './bootstrap/button'
export { default as Carousel } from './bootstrap/carousel'
export { default as Collapse } from './bootstrap/collapse'
export { default as Dropdown } from './bootstrap/dropdown'
export { default as Modal } from './bootstrap/modal'
export { default as Offcanvas } from './bootstrap/offcanvas'
export { default as Popover } from './bootstrap/popover'
export { default as ScrollSpy } from './bootstrap/scrollspy'
export { default as Tab } from './bootstrap/tab'
export { default as Toast } from './bootstrap/toast'
export { default as Tooltip } from './bootstrap/tooltip'
// Re-export everything as namespace for backward compatibility
export * as bootstrap from 'bootstrap'
import Alert from './bootstrap/alert'
import Button from './bootstrap/button'
import Carousel from './bootstrap/carousel'
import Collapse from './bootstrap/collapse'
import Dropdown from './bootstrap/dropdown'
import Modal from './bootstrap/modal'
import Offcanvas from './bootstrap/offcanvas'
import Popover from './bootstrap/popover'
import ScrollSpy from './bootstrap/scrollspy'
import Tab from './bootstrap/tab'
import Toast from './bootstrap/toast'
import Tooltip from './bootstrap/tooltip'
export const bootstrap = {
Alert,
Button,
Carousel,
Collapse,
Dropdown,
Modal,
Offcanvas,
Popover,
ScrollSpy,
Tab,
Toast,
Tooltip
}

View File

@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2011-2025 The Bootstrap Authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View File

@@ -0,0 +1,48 @@
/**
* --------------------------------------------------------------------------
* Bootstrap alert.ts
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* --------------------------------------------------------------------------
*/
import BaseComponent from './base-component'
import EventHandler from './dom/event-handler.js'
import { enableDismissTrigger } from './util/component-functions'
const NAME = 'alert'
const DATA_KEY = 'bs.alert'
const EVENT_KEY = `.${DATA_KEY}`
const EVENT_CLOSE = `close${EVENT_KEY}`
const EVENT_CLOSED = `closed${EVENT_KEY}`
const CLASS_NAME_FADE = 'fade'
const CLASS_NAME_SHOW = 'show'
class Alert extends BaseComponent {
static get NAME(): string {
return NAME
}
close(): void {
const closeEvent = EventHandler.trigger(this._element, EVENT_CLOSE)
if (closeEvent?.defaultPrevented) {
return
}
this._element.classList.remove(CLASS_NAME_SHOW)
const isAnimated = this._element.classList.contains(CLASS_NAME_FADE)
this._queueCallback(() => this._destroyElement(), this._element, isAnimated)
}
_destroyElement(): void {
this._element.remove()
EventHandler.trigger(this._element, EVENT_CLOSED)
this.dispose()
}
}
enableDismissTrigger(Alert, 'close')
export default Alert

View File

@@ -0,0 +1,81 @@
/**
* --------------------------------------------------------------------------
* Bootstrap base-component.ts
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* --------------------------------------------------------------------------
*/
import Data from './dom/data.js'
import EventHandler from './dom/event-handler.js'
import Config from './util/config'
import { executeAfterTransition, getElement } from './util/index.js'
import type { BaseComponentStatic, ComponentConfig, ElementSelector } from './types'
const VERSION = '5.3.8'
class BaseComponent extends Config {
_element!: HTMLElement
_config!: ComponentConfig
constructor(element: ElementSelector, config?: ComponentConfig) {
super()
const resolved = getElement(element)
if (!resolved) {
return
}
this._element = resolved
this._config = this._getConfig(config)
const ctor = this.constructor as unknown as BaseComponentStatic
Data.set(this._element, ctor.DATA_KEY, this)
}
dispose(): void {
const ctor = this.constructor as unknown as BaseComponentStatic
Data.remove(this._element, ctor.DATA_KEY)
EventHandler.off(this._element, ctor.EVENT_KEY)
for (const propertyName of Object.getOwnPropertyNames(this)) {
(this as Record<string, unknown>)[propertyName] = null
}
}
_queueCallback(callback: () => void, element: HTMLElement, isAnimated = true): void {
executeAfterTransition(callback, element, isAnimated)
}
_getConfig(config?: ComponentConfig): ComponentConfig {
config = this._mergeConfigObj(config, this._element)
config = this._configAfterMerge(config)
this._typeCheckConfig(config)
return config
}
static getInstance(element: ElementSelector): BaseComponent | null {
return Data.get(getElement(element)!, this.DATA_KEY)
}
static getOrCreateInstance(element: ElementSelector, config: ComponentConfig = {}): BaseComponent {
return this.getInstance(element) || new this(element, typeof config === 'object' ? config : null)
}
static get VERSION(): string {
return VERSION
}
static get DATA_KEY(): string {
return `bs.${this.NAME}`
}
static get EVENT_KEY(): string {
return `.${this.DATA_KEY}`
}
static eventName(name: string): string {
return `${name}${this.EVENT_KEY}`
}
}
export default BaseComponent

View File

@@ -0,0 +1,42 @@
/**
* --------------------------------------------------------------------------
* Bootstrap button.ts
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* --------------------------------------------------------------------------
*/
import BaseComponent from './base-component'
import EventHandler from './dom/event-handler.js'
const NAME = 'button'
const DATA_KEY = 'bs.button'
const EVENT_KEY = `.${DATA_KEY}`
const DATA_API_KEY = '.data-api'
const CLASS_NAME_ACTIVE = 'active'
const SELECTOR_DATA_TOGGLE = '[data-bs-toggle="button"], [data-tblr-toggle="button"]'
const EVENT_CLICK_DATA_API = `click${EVENT_KEY}${DATA_API_KEY}`
class Button extends BaseComponent {
static get NAME(): string {
return NAME
}
toggle(): void {
this._element.setAttribute('aria-pressed', String(this._element.classList.toggle(CLASS_NAME_ACTIVE)))
}
}
EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, (event: Event) => {
event.preventDefault()
const target = (event.target as HTMLElement)?.closest(SELECTOR_DATA_TOGGLE) as HTMLElement | null
if (!target) {
return
}
const data = Button.getOrCreateInstance(target) as Button
data.toggle()
})
export default Button

View File

@@ -0,0 +1,427 @@
/**
* --------------------------------------------------------------------------
* Bootstrap carousel.ts
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* --------------------------------------------------------------------------
*/
import BaseComponent from './base-component'
import EventHandler from './dom/event-handler'
import Manipulator from './dom/manipulator'
import SelectorEngine from './dom/selector-engine'
import {
getNextActiveElement,
isRTL,
isVisible,
reflow,
triggerTransitionEnd
} from './util/index'
import Swipe from './util/swipe'
import type { ComponentConfig, ComponentConfigType } from './types'
const NAME = 'carousel'
const DATA_KEY = 'bs.carousel'
const EVENT_KEY = `.${DATA_KEY}`
const DATA_API_KEY = '.data-api'
const ARROW_LEFT_KEY = 'ArrowLeft'
const ARROW_RIGHT_KEY = 'ArrowRight'
const TOUCHEVENT_COMPAT_WAIT = 500
const ORDER_NEXT = 'next'
const ORDER_PREV = 'prev'
const DIRECTION_LEFT = 'left'
const DIRECTION_RIGHT = 'right'
const EVENT_SLIDE = `slide${EVENT_KEY}`
const EVENT_SLID = `slid${EVENT_KEY}`
const EVENT_KEYDOWN = `keydown${EVENT_KEY}`
const EVENT_MOUSEENTER = `mouseenter${EVENT_KEY}`
const EVENT_MOUSELEAVE = `mouseleave${EVENT_KEY}`
const EVENT_DRAG_START = `dragstart${EVENT_KEY}`
const EVENT_LOAD_DATA_API = `load${EVENT_KEY}${DATA_API_KEY}`
const EVENT_CLICK_DATA_API = `click${EVENT_KEY}${DATA_API_KEY}`
const CLASS_NAME_CAROUSEL = 'carousel'
const CLASS_NAME_ACTIVE = 'active'
const CLASS_NAME_SLIDE = 'slide'
const CLASS_NAME_END = 'carousel-item-end'
const CLASS_NAME_START = 'carousel-item-start'
const CLASS_NAME_NEXT = 'carousel-item-next'
const CLASS_NAME_PREV = 'carousel-item-prev'
const SELECTOR_ACTIVE = '.active'
const SELECTOR_ITEM = '.carousel-item'
const SELECTOR_ACTIVE_ITEM = SELECTOR_ACTIVE + SELECTOR_ITEM
const SELECTOR_ITEM_IMG = '.carousel-item img'
const SELECTOR_INDICATORS = '.carousel-indicators'
const SELECTOR_DATA_SLIDE = '[data-bs-slide], [data-bs-slide-to], [data-tblr-slide], [data-tblr-slide-to]'
const SELECTOR_DATA_RIDE = '[data-bs-ride="carousel"], [data-tblr-ride="carousel"]'
const KEY_TO_DIRECTION: Record<string, string> = {
[ARROW_LEFT_KEY]: DIRECTION_RIGHT,
[ARROW_RIGHT_KEY]: DIRECTION_LEFT
}
const Default: ComponentConfig = {
interval: 5000,
keyboard: true,
pause: 'hover',
ride: false,
touch: true,
wrap: true
}
const DefaultType: ComponentConfigType = {
interval: '(number|boolean)',
keyboard: 'boolean',
pause: '(string|boolean)',
ride: '(boolean|string)',
touch: 'boolean',
wrap: 'boolean'
}
class Carousel extends BaseComponent {
_interval: ReturnType<typeof setInterval> | null
_activeElement: HTMLElement | null
_isSliding: boolean
touchTimeout: ReturnType<typeof setTimeout> | null
_swipeHelper: Swipe | null
_indicatorsElement: HTMLElement | null
constructor(element: HTMLElement | string, config?: Partial<ComponentConfig>) {
super(element, config)
this._interval = null
this._activeElement = null
this._isSliding = false
this.touchTimeout = null
this._swipeHelper = null
this._indicatorsElement = SelectorEngine.findOne(SELECTOR_INDICATORS, this._element)
this._addEventListeners()
if (this._config.ride === CLASS_NAME_CAROUSEL) {
this.cycle()
}
}
static get Default(): ComponentConfig {
return Default
}
static get DefaultType(): ComponentConfigType {
return DefaultType
}
static get NAME(): string {
return NAME
}
next(): void {
this._slide(ORDER_NEXT)
}
nextWhenVisible(): void {
if (!document.hidden && isVisible(this._element)) {
this.next()
}
}
prev(): void {
this._slide(ORDER_PREV)
}
pause(): void {
if (this._isSliding) {
triggerTransitionEnd(this._element)
}
this._clearInterval()
}
cycle(): void {
this._clearInterval()
this._updateInterval()
this._interval = setInterval(() => this.nextWhenVisible(), this._config.interval as number)
}
_maybeEnableCycle(): void {
if (!this._config.ride) {
return
}
if (this._isSliding) {
EventHandler.one(this._element, EVENT_SLID, () => this.cycle())
return
}
this.cycle()
}
to(index: number): void {
const items = this._getItems()
if (index > items.length - 1 || index < 0) {
return
}
if (this._isSliding) {
EventHandler.one(this._element, EVENT_SLID, () => this.to(index))
return
}
const activeIndex = this._getItemIndex(this._getActive()!)
if (activeIndex === index) {
return
}
const order = index > activeIndex ? ORDER_NEXT : ORDER_PREV
this._slide(order, items[index])
}
dispose(): void {
if (this._swipeHelper) {
this._swipeHelper.dispose()
}
super.dispose()
}
_configAfterMerge(config: ComponentConfig): ComponentConfig {
config.defaultInterval = config.interval
return config
}
_addEventListeners(): void {
if (this._config.keyboard) {
EventHandler.on(this._element, EVENT_KEYDOWN, (event: Event) => this._keydown(event as KeyboardEvent))
}
if (this._config.pause === 'hover') {
EventHandler.on(this._element, EVENT_MOUSEENTER, () => this.pause())
EventHandler.on(this._element, EVENT_MOUSELEAVE, () => this._maybeEnableCycle())
}
if (this._config.touch && Swipe.isSupported()) {
this._addTouchEventListeners()
}
}
_addTouchEventListeners(): void {
for (const img of SelectorEngine.find(SELECTOR_ITEM_IMG, this._element)) {
EventHandler.on(img, EVENT_DRAG_START, (event: Event) => event.preventDefault())
}
const endCallBack = () => {
if (this._config.pause !== 'hover') {
return
}
this.pause()
if (this.touchTimeout) {
clearTimeout(this.touchTimeout)
}
this.touchTimeout = setTimeout(() => this._maybeEnableCycle(), TOUCHEVENT_COMPAT_WAIT + (this._config.interval as number))
}
const swipeConfig = {
leftCallback: () => this._slide(this._directionToOrder(DIRECTION_LEFT)),
rightCallback: () => this._slide(this._directionToOrder(DIRECTION_RIGHT)),
endCallback: endCallBack
}
this._swipeHelper = new Swipe(this._element, swipeConfig)
}
_keydown(event: KeyboardEvent): void {
if (/input|textarea/i.test((event.target as HTMLElement).tagName)) {
return
}
const direction = KEY_TO_DIRECTION[event.key]
if (direction) {
event.preventDefault()
this._slide(this._directionToOrder(direction))
}
}
_getItemIndex(element: HTMLElement): number {
return this._getItems().indexOf(element)
}
_setActiveIndicatorElement(index: number): void {
if (!this._indicatorsElement) {
return
}
const activeIndicator = SelectorEngine.findOne(SELECTOR_ACTIVE, this._indicatorsElement)
activeIndicator!.classList.remove(CLASS_NAME_ACTIVE)
activeIndicator!.removeAttribute('aria-current')
const newActiveIndicator = SelectorEngine.findOne(`[data-bs-slide-to="${index}"], [data-tblr-slide-to="${index}"]`, this._indicatorsElement)
if (newActiveIndicator) {
newActiveIndicator.classList.add(CLASS_NAME_ACTIVE)
newActiveIndicator.setAttribute('aria-current', 'true')
}
}
_updateInterval(): void {
const element = this._activeElement || this._getActive()
if (!element) {
return
}
const elementInterval = Number.parseInt(element.getAttribute('data-bs-interval') || element.getAttribute('data-tblr-interval') || '', 10)
this._config.interval = elementInterval || this._config.defaultInterval
}
_slide(order: string, element: HTMLElement | null = null): void {
if (this._isSliding) {
return
}
const activeElement = this._getActive()
const isNext = order === ORDER_NEXT
const nextElement = element || getNextActiveElement(this._getItems(), activeElement!, isNext, this._config.wrap as boolean) as HTMLElement
if (nextElement === activeElement) {
return
}
const nextElementIndex = this._getItemIndex(nextElement)
const triggerEvent = (eventName: string) => {
return EventHandler.trigger(this._element, eventName, {
relatedTarget: nextElement,
direction: this._orderToDirection(order),
from: this._getItemIndex(activeElement!),
to: nextElementIndex
})
}
const slideEvent = triggerEvent(EVENT_SLIDE)
if (slideEvent.defaultPrevented) {
return
}
if (!activeElement || !nextElement) {
return
}
const isCycling = Boolean(this._interval)
this.pause()
this._isSliding = true
this._setActiveIndicatorElement(nextElementIndex)
this._activeElement = nextElement
const directionalClassName = isNext ? CLASS_NAME_START : CLASS_NAME_END
const orderClassName = isNext ? CLASS_NAME_NEXT : CLASS_NAME_PREV
nextElement.classList.add(orderClassName)
reflow(nextElement)
activeElement.classList.add(directionalClassName)
nextElement.classList.add(directionalClassName)
const completeCallBack = () => {
nextElement.classList.remove(directionalClassName, orderClassName)
nextElement.classList.add(CLASS_NAME_ACTIVE)
activeElement.classList.remove(CLASS_NAME_ACTIVE, orderClassName, directionalClassName)
this._isSliding = false
triggerEvent(EVENT_SLID)
}
this._queueCallback(completeCallBack, activeElement, this._isAnimated())
if (isCycling) {
this.cycle()
}
}
_isAnimated(): boolean {
return this._element.classList.contains(CLASS_NAME_SLIDE)
}
_getActive(): HTMLElement | null {
return SelectorEngine.findOne(SELECTOR_ACTIVE_ITEM, this._element)
}
_getItems(): HTMLElement[] {
return SelectorEngine.find(SELECTOR_ITEM, this._element)
}
_clearInterval(): void {
if (this._interval) {
clearInterval(this._interval)
this._interval = null
}
}
_directionToOrder(direction: string): string {
if (isRTL()) {
return direction === DIRECTION_LEFT ? ORDER_PREV : ORDER_NEXT
}
return direction === DIRECTION_LEFT ? ORDER_NEXT : ORDER_PREV
}
_orderToDirection(order: string): string {
if (isRTL()) {
return order === ORDER_PREV ? DIRECTION_LEFT : DIRECTION_RIGHT
}
return order === ORDER_PREV ? DIRECTION_RIGHT : DIRECTION_LEFT
}
}
EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_SLIDE, function (this: HTMLElement, event: Event) {
const target = SelectorEngine.getElementFromSelector(this)
if (!target || !target.classList.contains(CLASS_NAME_CAROUSEL)) {
return
}
event.preventDefault()
const carousel = Carousel.getOrCreateInstance(target) as Carousel
const slideIndex = this.getAttribute('data-bs-slide-to') || this.getAttribute('data-tblr-slide-to')
if (slideIndex) {
carousel.to(Number(slideIndex))
carousel._maybeEnableCycle()
return
}
if (Manipulator.getDataAttribute(this, 'slide') === 'next') {
carousel.next()
carousel._maybeEnableCycle()
return
}
carousel.prev()
carousel._maybeEnableCycle()
})
EventHandler.on(window, EVENT_LOAD_DATA_API, () => {
const carousels = SelectorEngine.find(SELECTOR_DATA_RIDE)
for (const carousel of carousels) {
Carousel.getOrCreateInstance(carousel)
}
})
export default Carousel

View File

@@ -0,0 +1,253 @@
/**
* --------------------------------------------------------------------------
* Bootstrap collapse.ts
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* --------------------------------------------------------------------------
*/
import BaseComponent from './base-component'
import EventHandler from './dom/event-handler'
import SelectorEngine from './dom/selector-engine'
import { getElement, reflow } from './util/index'
import type { ComponentConfig, ComponentConfigType, ElementSelector } from './types'
const NAME = 'collapse'
const DATA_KEY = 'bs.collapse'
const EVENT_KEY = `.${DATA_KEY}`
const DATA_API_KEY = '.data-api'
const EVENT_SHOW = `show${EVENT_KEY}`
const EVENT_SHOWN = `shown${EVENT_KEY}`
const EVENT_HIDE = `hide${EVENT_KEY}`
const EVENT_HIDDEN = `hidden${EVENT_KEY}`
const EVENT_CLICK_DATA_API = `click${EVENT_KEY}${DATA_API_KEY}`
const CLASS_NAME_SHOW = 'show'
const CLASS_NAME_COLLAPSE = 'collapse'
const CLASS_NAME_COLLAPSING = 'collapsing'
const CLASS_NAME_COLLAPSED = 'collapsed'
const CLASS_NAME_DEEPER_CHILDREN = `:scope .${CLASS_NAME_COLLAPSE} .${CLASS_NAME_COLLAPSE}`
const CLASS_NAME_HORIZONTAL = 'collapse-horizontal'
const WIDTH = 'width'
const HEIGHT = 'height'
const SELECTOR_ACTIVES = '.collapse.show, .collapse.collapsing'
const SELECTOR_DATA_TOGGLE = '[data-bs-toggle="collapse"], [data-tblr-toggle="collapse"]'
const Default: ComponentConfig = {
parent: null,
toggle: true
}
const DefaultType: ComponentConfigType = {
parent: '(null|element)',
toggle: 'boolean'
}
class Collapse extends BaseComponent {
_isTransitioning: boolean
_triggerArray: HTMLElement[]
constructor(element: ElementSelector, config?: ComponentConfig) {
super(element, config)
this._isTransitioning = false
this._triggerArray = []
const toggleList = SelectorEngine.find(SELECTOR_DATA_TOGGLE)
for (const elem of toggleList) {
const selector = SelectorEngine.getSelectorFromElement(elem)
const filterElement = SelectorEngine.find(selector!)
.filter(foundElement => foundElement === this._element)
if (selector !== null && filterElement.length) {
this._triggerArray.push(elem)
}
}
this._initializeChildren()
if (!this._config.parent) {
this._addAriaAndCollapsedClass(this._triggerArray, this._isShown())
}
if (this._config.toggle) {
this.toggle()
}
}
static get Default(): ComponentConfig {
return Default
}
static get DefaultType(): ComponentConfigType {
return DefaultType
}
static get NAME(): string {
return NAME
}
toggle(): void {
if (this._isShown()) {
this.hide()
} else {
this.show()
}
}
show(): void {
if (this._isTransitioning || this._isShown()) {
return
}
let activeChildren: Collapse[] = []
if (this._config.parent) {
activeChildren = this._getFirstLevelChildren(SELECTOR_ACTIVES)
.filter(element => element !== this._element)
.map(element => Collapse.getOrCreateInstance(element, { toggle: false }) as Collapse)
}
if (activeChildren.length && activeChildren[0]._isTransitioning) {
return
}
const startEvent = EventHandler.trigger(this._element, EVENT_SHOW)
if (startEvent?.defaultPrevented) {
return
}
for (const activeInstance of activeChildren) {
activeInstance.hide()
}
const dimension = this._getDimension()
this._element.classList.remove(CLASS_NAME_COLLAPSE)
this._element.classList.add(CLASS_NAME_COLLAPSING)
this._element.style[dimension] = '0'
this._addAriaAndCollapsedClass(this._triggerArray, true)
this._isTransitioning = true
const complete = () => {
this._isTransitioning = false
this._element.classList.remove(CLASS_NAME_COLLAPSING)
this._element.classList.add(CLASS_NAME_COLLAPSE, CLASS_NAME_SHOW)
this._element.style[dimension] = ''
EventHandler.trigger(this._element, EVENT_SHOWN)
}
const capitalizedDimension = dimension[0].toUpperCase() + dimension.slice(1)
const scrollSize = `scroll${capitalizedDimension}` as 'scrollWidth' | 'scrollHeight'
this._queueCallback(complete, this._element, true)
this._element.style[dimension] = `${this._element[scrollSize]}px`
}
hide(): void {
if (this._isTransitioning || !this._isShown()) {
return
}
const startEvent = EventHandler.trigger(this._element, EVENT_HIDE)
if (startEvent?.defaultPrevented) {
return
}
const dimension = this._getDimension()
this._element.style[dimension] = `${this._element.getBoundingClientRect()[dimension]}px`
reflow(this._element)
this._element.classList.add(CLASS_NAME_COLLAPSING)
this._element.classList.remove(CLASS_NAME_COLLAPSE, CLASS_NAME_SHOW)
for (const trigger of this._triggerArray) {
const element = SelectorEngine.getElementFromSelector(trigger)
if (element && !this._isShown(element)) {
this._addAriaAndCollapsedClass([trigger], false)
}
}
this._isTransitioning = true
const complete = () => {
this._isTransitioning = false
this._element.classList.remove(CLASS_NAME_COLLAPSING)
this._element.classList.add(CLASS_NAME_COLLAPSE)
EventHandler.trigger(this._element, EVENT_HIDDEN)
}
this._element.style[dimension] = ''
this._queueCallback(complete, this._element, true)
}
_isShown(element: HTMLElement = this._element): boolean {
return element.classList.contains(CLASS_NAME_SHOW)
}
_configAfterMerge(config: ComponentConfig): ComponentConfig {
config.toggle = Boolean(config.toggle)
config.parent = getElement(config.parent)
return config
}
_getDimension(): typeof WIDTH | typeof HEIGHT {
return this._element.classList.contains(CLASS_NAME_HORIZONTAL) ? WIDTH : HEIGHT
}
_initializeChildren(): void {
if (!this._config.parent) {
return
}
const children = this._getFirstLevelChildren(SELECTOR_DATA_TOGGLE)
for (const element of children) {
const selected = SelectorEngine.getElementFromSelector(element)
if (selected) {
this._addAriaAndCollapsedClass([element], this._isShown(selected))
}
}
}
_getFirstLevelChildren(selector: string): HTMLElement[] {
const children = SelectorEngine.find(CLASS_NAME_DEEPER_CHILDREN, this._config.parent as HTMLElement)
return SelectorEngine.find(selector, this._config.parent as HTMLElement).filter(element => !children.includes(element))
}
_addAriaAndCollapsedClass(triggerArray: HTMLElement[], isOpen: boolean): void {
if (!triggerArray.length) {
return
}
for (const element of triggerArray) {
element.classList.toggle(CLASS_NAME_COLLAPSED, !isOpen)
element.setAttribute('aria-expanded', String(isOpen))
}
}
}
EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function (this: HTMLElement, event: Event) {
if ((event.target as HTMLElement).tagName === 'A' || ((event as any).delegateTarget && (event as any).delegateTarget.tagName === 'A')) {
event.preventDefault()
}
for (const element of SelectorEngine.getMultipleElementsFromSelector(this)) {
(Collapse.getOrCreateInstance(element, { toggle: false }) as Collapse).toggle()
}
})
export default Collapse

View File

@@ -0,0 +1,50 @@
/**
* --------------------------------------------------------------------------
* Bootstrap dom/data.ts
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* --------------------------------------------------------------------------
*/
const elementMap = new Map<HTMLElement, Map<string, object>>()
const Data = {
set(element: HTMLElement, key: string, instance: object): void {
if (!elementMap.has(element)) {
elementMap.set(element, new Map())
}
const instanceMap = elementMap.get(element)!
if (!instanceMap.has(key) && instanceMap.size !== 0) {
// eslint-disable-next-line no-console
console.error(`Bootstrap doesn't allow more than one instance per element. Bound instance: ${Array.from(instanceMap.keys())[0]}.`)
return
}
instanceMap.set(key, instance)
},
get<T = object>(element: HTMLElement, key: string): T | null {
if (elementMap.has(element)) {
return (elementMap.get(element)!.get(key) as T) || null
}
return null
},
remove(element: HTMLElement, key: string): void {
if (!elementMap.has(element)) {
return
}
const instanceMap = elementMap.get(element)!
instanceMap.delete(key)
if (instanceMap.size === 0) {
elementMap.delete(element)
}
}
}
export default Data

View File

@@ -0,0 +1,314 @@
/**
* --------------------------------------------------------------------------
* Bootstrap dom/event-handler.ts
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* --------------------------------------------------------------------------
*/
type EventCallback = (this: EventTarget, ...args: unknown[]) => void
interface BootstrapHandler {
(event: Event): void
oneOff?: boolean
delegationSelector?: string | null
callable?: EventCallback
uidEvent?: string | number
}
interface EventableElement extends EventTarget {
uidEvent?: string | number
}
const namespaceRegex = /[^.]*(?=\..*)\.|.*/
const stripNameRegex = /\..*/
const stripUidRegex = /::\d+$/
const eventRegistry: Record<string | number, Record<string, Record<string | number, BootstrapHandler>>> = {}
let uidEvent = 1
const customEvents: Record<string, string> = {
mouseenter: 'mouseover',
mouseleave: 'mouseout'
}
const nativeEvents = new Set([
'click',
'dblclick',
'mouseup',
'mousedown',
'contextmenu',
'mousewheel',
'DOMMouseScroll',
'mouseover',
'mouseout',
'mousemove',
'selectstart',
'selectend',
'keydown',
'keypress',
'keyup',
'orientationchange',
'touchstart',
'touchmove',
'touchend',
'touchcancel',
'pointerdown',
'pointermove',
'pointerup',
'pointerleave',
'pointercancel',
'gesturestart',
'gesturechange',
'gestureend',
'focus',
'blur',
'change',
'reset',
'select',
'submit',
'focusin',
'focusout',
'load',
'unload',
'beforeunload',
'resize',
'move',
'DOMContentLoaded',
'readystatechange',
'error',
'abort',
'scroll'
])
function makeEventUid(element: EventableElement | EventCallback, uid?: string): string | number {
return (uid && `${uid}::${uidEvent++}`) || (element as EventableElement).uidEvent || uidEvent++
}
function getElementEvents(element: EventableElement): Record<string, Record<string | number, BootstrapHandler>> {
const uid = makeEventUid(element)
element.uidEvent = uid
eventRegistry[uid] = eventRegistry[uid] || {}
return eventRegistry[uid]
}
function bootstrapHandler(element: EventTarget, fn: EventCallback): BootstrapHandler {
return function handler(event: Event) {
hydrateObj(event, { delegateTarget: element })
if ((handler as BootstrapHandler).oneOff) {
EventHandler.off(element, event.type, fn)
}
return fn.apply(element, [event])
} as BootstrapHandler
}
function bootstrapDelegationHandler(element: EventTarget, selector: string, fn: EventCallback): BootstrapHandler {
return function handler(this: EventTarget, event: Event) {
const domElements = (element as HTMLElement).querySelectorAll(selector)
for (let { target } = event; target && target !== this; target = (target as HTMLElement).parentNode) {
for (const domElement of domElements) {
if (domElement !== target) {
continue
}
hydrateObj(event, { delegateTarget: target })
if ((handler as BootstrapHandler).oneOff) {
EventHandler.off(element, event.type, selector, fn)
}
return fn.apply(target, [event])
}
}
} as BootstrapHandler
}
function findHandler(
events: Record<string | number, BootstrapHandler>,
callable: EventCallback,
delegationSelector: string | null = null
): BootstrapHandler | undefined {
return Object.values(events)
.find(event => event.callable === callable && event.delegationSelector === delegationSelector)
}
function normalizeParameters(
originalTypeEvent: string,
handler: string | EventCallback | undefined,
delegationFunction: EventCallback | undefined
): [boolean, EventCallback, string] {
const isDelegated = typeof handler === 'string'
const callable = isDelegated ? delegationFunction! : (handler || delegationFunction)!
let typeEvent = getTypeEvent(originalTypeEvent)
if (!nativeEvents.has(typeEvent)) {
typeEvent = originalTypeEvent
}
return [isDelegated, callable, typeEvent]
}
function addHandler(
element: EventTarget | null,
originalTypeEvent: string,
handler: string | EventCallback | undefined,
delegationFunction: EventCallback | undefined,
oneOff: boolean
): void {
if (typeof originalTypeEvent !== 'string' || !element) {
return
}
let [isDelegated, callable, typeEvent] = normalizeParameters(originalTypeEvent, handler, delegationFunction)
if (originalTypeEvent in customEvents) {
const wrapFunction = (fn: EventCallback): EventCallback => {
return function (this: EventTarget, event: unknown) {
const evt = event as MouseEvent & { delegateTarget: HTMLElement }
if (!evt.relatedTarget || (evt.relatedTarget !== evt.delegateTarget && !evt.delegateTarget.contains(evt.relatedTarget as Node))) {
return fn.call(this, event)
}
}
}
callable = wrapFunction(callable)
}
const events = getElementEvents(element)
const handlers = events[typeEvent] || (events[typeEvent] = {})
const previousFunction = findHandler(handlers, callable, isDelegated ? handler as string : null)
if (previousFunction) {
previousFunction.oneOff = previousFunction.oneOff && oneOff
return
}
const uid = makeEventUid(callable, originalTypeEvent.replace(namespaceRegex, ''))
const fn: BootstrapHandler = isDelegated ?
bootstrapDelegationHandler(element, handler as string, callable) :
bootstrapHandler(element, callable)
fn.delegationSelector = isDelegated ? handler as string : null
fn.callable = callable
fn.oneOff = oneOff
fn.uidEvent = uid
handlers[uid] = fn
element.addEventListener(typeEvent, fn, isDelegated)
}
function removeHandler(
element: EventTarget,
events: Record<string, Record<string | number, BootstrapHandler>>,
typeEvent: string,
handler: EventCallback,
delegationSelector?: string | null
): void {
const fn = findHandler(events[typeEvent], handler, delegationSelector ?? null)
if (!fn) {
return
}
element.removeEventListener(typeEvent, fn, Boolean(delegationSelector))
delete events[typeEvent][fn.uidEvent!]
}
function removeNamespacedHandlers(
element: EventTarget,
events: Record<string, Record<string | number, BootstrapHandler>>,
typeEvent: string,
namespace: string
): void {
const storeElementEvent = events[typeEvent] || {}
for (const [handlerKey, event] of Object.entries(storeElementEvent)) {
if (handlerKey.includes(namespace)) {
removeHandler(element, events, typeEvent, event.callable!, event.delegationSelector)
}
}
}
function getTypeEvent(event: string): string {
event = event.replace(stripNameRegex, '')
return customEvents[event] || event
}
const EventHandler = {
on(element: EventTarget | null, event: string, handler: string | EventCallback, delegationFunction?: EventCallback): void {
addHandler(element, event, handler, delegationFunction, false)
},
one(element: EventTarget | null, event: string, handler: string | EventCallback, delegationFunction?: EventCallback): void {
addHandler(element, event, handler, delegationFunction, true)
},
off(element: EventTarget | null, originalTypeEvent: string, handler?: string | EventCallback, delegationFunction?: EventCallback): void {
if (typeof originalTypeEvent !== 'string' || !element) {
return
}
const [isDelegated, callable, typeEvent] = normalizeParameters(originalTypeEvent, handler, delegationFunction)
const inNamespace = typeEvent !== originalTypeEvent
const events = getElementEvents(element)
const storeElementEvent = events[typeEvent] || {}
const isNamespace = originalTypeEvent.startsWith('.')
if (typeof callable !== 'undefined') {
if (!Object.keys(storeElementEvent).length) {
return
}
removeHandler(element, events, typeEvent, callable, isDelegated ? handler as string : null)
return
}
if (isNamespace) {
for (const elementEvent of Object.keys(events)) {
removeNamespacedHandlers(element, events, elementEvent, originalTypeEvent.slice(1))
}
}
for (const [keyHandlers, event] of Object.entries(storeElementEvent)) {
const handlerKey = keyHandlers.replace(stripUidRegex, '')
if (!inNamespace || originalTypeEvent.includes(handlerKey)) {
removeHandler(element, events, typeEvent, event.callable!, event.delegationSelector)
}
}
},
trigger(element: EventTarget | null, event: string, args?: Record<string, unknown>): Event | null {
if (typeof event !== 'string' || !element) {
return null
}
const evt = hydrateObj(new Event(event, { bubbles: true, cancelable: true }), args)
element.dispatchEvent(evt)
return evt
}
}
function hydrateObj<T extends object>(obj: T, meta: Record<string, unknown> = {}): T {
for (const [key, value] of Object.entries(meta)) {
try {
(obj as Record<string, unknown>)[key] = value
} catch {
Object.defineProperty(obj, key, {
configurable: true,
get() {
return value
}
})
}
}
return obj
}
export default EventHandler

View File

@@ -0,0 +1,89 @@
/**
* --------------------------------------------------------------------------
* Bootstrap dom/manipulator.ts
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* --------------------------------------------------------------------------
*/
type DataValue = string | number | boolean | null | Record<string, unknown>
function normalizeData(value: string): DataValue {
if (value === 'true') {
return true
}
if (value === 'false') {
return false
}
if (value === Number(value).toString()) {
return Number(value)
}
if (value === '' || value === 'null') {
return null
}
if (typeof value !== 'string') {
return value
}
try {
return JSON.parse(decodeURIComponent(value))
} catch {
return value
}
}
function normalizeDataKey(key: string): string {
return key.replace(/[A-Z]/g, chr => `-${chr.toLowerCase()}`)
}
const PREFIXES = ['tblr', 'bs'] as const
const Manipulator = {
setDataAttribute(element: HTMLElement, key: string, value: string): void {
element.setAttribute(`data-tblr-${normalizeDataKey(key)}`, value)
},
removeDataAttribute(element: HTMLElement, key: string): void {
for (const prefix of PREFIXES) {
element.removeAttribute(`data-${prefix}-${normalizeDataKey(key)}`)
}
},
getDataAttributes(element: HTMLElement | null): Record<string, DataValue> {
if (!element) {
return {}
}
const attributes: Record<string, DataValue> = {}
for (const prefix of PREFIXES) {
const keys = Object.keys(element.dataset).filter(key => key.startsWith(prefix) && !key.startsWith(`${prefix}Config`))
for (const key of keys) {
let pureKey = key.replace(new RegExp(`^${prefix}`), '')
pureKey = pureKey.charAt(0).toLowerCase() + pureKey.slice(1)
if (!(pureKey in attributes)) {
attributes[pureKey] = normalizeData(element.dataset[key]!)
}
}
}
return attributes
},
getDataAttribute(element: HTMLElement, key: string): DataValue {
for (const prefix of PREFIXES) {
const value = element.getAttribute(`data-${prefix}-${normalizeDataKey(key)}`)
if (value !== null) {
return normalizeData(value)
}
}
return null
}
}
export default Manipulator

View File

@@ -0,0 +1,121 @@
/**
* --------------------------------------------------------------------------
* Bootstrap dom/selector-engine.ts
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* --------------------------------------------------------------------------
*/
import { isDisabled, isVisible, parseSelector } from '../util/index'
const getSelector = (element: HTMLElement): string | null => {
let selector = element.getAttribute('data-tblr-target') || element.getAttribute('data-bs-target')
if (!selector || selector === '#') {
let hrefAttribute = element.getAttribute('href')
if (!hrefAttribute || (!hrefAttribute.includes('#') && !hrefAttribute.startsWith('.'))) {
return null
}
if (hrefAttribute.includes('#') && !hrefAttribute.startsWith('#')) {
hrefAttribute = `#${hrefAttribute.split('#')[1]}`
}
selector = hrefAttribute && hrefAttribute !== '#' ? hrefAttribute.trim() : null
}
return selector ? selector.split(',').map(sel => parseSelector(sel)).join(',') : null
}
const SelectorEngine = {
find(selector: string, element: Element = document.documentElement): HTMLElement[] {
return Array.from(element.querySelectorAll<HTMLElement>(selector))
},
findOne(selector: string, element: Element = document.documentElement): HTMLElement | null {
return element.querySelector<HTMLElement>(selector)
},
children(element: HTMLElement, selector: string): HTMLElement[] {
return Array.from(element.children).filter(child => child.matches(selector)) as HTMLElement[]
},
parents(element: HTMLElement, selector: string): HTMLElement[] {
const parents: HTMLElement[] = []
let ancestor = element.parentNode && (element.parentNode as HTMLElement).closest(selector)
while (ancestor) {
parents.push(ancestor as HTMLElement)
ancestor = ancestor.parentNode && (ancestor.parentNode as HTMLElement).closest(selector)
}
return parents
},
prev(element: HTMLElement, selector: string): HTMLElement[] {
let previous = element.previousElementSibling
while (previous) {
if (previous.matches(selector)) {
return [previous as HTMLElement]
}
previous = previous.previousElementSibling
}
return []
},
next(element: HTMLElement, selector: string): HTMLElement[] {
let next = element.nextElementSibling
while (next) {
if (next.matches(selector)) {
return [next as HTMLElement]
}
next = next.nextElementSibling
}
return []
},
focusableChildren(element: HTMLElement): HTMLElement[] {
const focusables = [
'a',
'button',
'input',
'textarea',
'select',
'details',
'[tabindex]',
'[contenteditable="true"]'
].map(selector => `${selector}:not([tabindex^="-"])`).join(',')
return this.find(focusables, element).filter(el => !isDisabled(el) && isVisible(el))
},
getSelectorFromElement(element: HTMLElement): string | null {
const selector = getSelector(element)
if (selector) {
return SelectorEngine.findOne(selector) ? selector : null
}
return null
},
getElementFromSelector(element: HTMLElement): HTMLElement | null {
const selector = getSelector(element)
return selector ? SelectorEngine.findOne(selector) : null
},
getMultipleElementsFromSelector(element: HTMLElement): HTMLElement[] {
const selector = getSelector(element)
return selector ? SelectorEngine.find(selector) : []
}
}
export default SelectorEngine

View File

@@ -0,0 +1,406 @@
/**
* --------------------------------------------------------------------------
* Bootstrap dropdown.ts
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* --------------------------------------------------------------------------
*/
import * as Popper from '@popperjs/core'
import BaseComponent from './base-component'
import EventHandler from './dom/event-handler'
import Manipulator from './dom/manipulator'
import SelectorEngine from './dom/selector-engine'
import {
execute,
getElement,
getNextActiveElement,
isDisabled,
isElement,
isRTL,
isVisible,
noop
} from './util/index'
import type { ComponentConfig, ComponentConfigType } from './types'
const NAME = 'dropdown'
const DATA_KEY = 'bs.dropdown'
const EVENT_KEY = `.${DATA_KEY}`
const DATA_API_KEY = '.data-api'
const ESCAPE_KEY = 'Escape'
const TAB_KEY = 'Tab'
const ARROW_UP_KEY = 'ArrowUp'
const ARROW_DOWN_KEY = 'ArrowDown'
const RIGHT_MOUSE_BUTTON = 2
const EVENT_HIDE = `hide${EVENT_KEY}`
const EVENT_HIDDEN = `hidden${EVENT_KEY}`
const EVENT_SHOW = `show${EVENT_KEY}`
const EVENT_SHOWN = `shown${EVENT_KEY}`
const EVENT_CLICK_DATA_API = `click${EVENT_KEY}${DATA_API_KEY}`
const EVENT_KEYDOWN_DATA_API = `keydown${EVENT_KEY}${DATA_API_KEY}`
const EVENT_KEYUP_DATA_API = `keyup${EVENT_KEY}${DATA_API_KEY}`
const CLASS_NAME_SHOW = 'show'
const CLASS_NAME_DROPUP = 'dropup'
const CLASS_NAME_DROPEND = 'dropend'
const CLASS_NAME_DROPSTART = 'dropstart'
const CLASS_NAME_DROPUP_CENTER = 'dropup-center'
const CLASS_NAME_DROPDOWN_CENTER = 'dropdown-center'
const SELECTOR_DATA_TOGGLE = '[data-bs-toggle="dropdown"]:not(.disabled):not(:disabled), [data-tblr-toggle="dropdown"]:not(.disabled):not(:disabled)'
const SELECTOR_DATA_TOGGLE_SHOWN = `.${CLASS_NAME_SHOW}[data-bs-toggle="dropdown"], .${CLASS_NAME_SHOW}[data-tblr-toggle="dropdown"]`
const SELECTOR_MENU = '.dropdown-menu'
const SELECTOR_NAVBAR = '.navbar'
const SELECTOR_NAVBAR_NAV = '.navbar-nav'
const SELECTOR_VISIBLE_ITEMS = '.dropdown-menu .dropdown-item:not(.disabled):not(:disabled)'
const PLACEMENT_TOP = isRTL() ? 'top-end' : 'top-start'
const PLACEMENT_TOPEND = isRTL() ? 'top-start' : 'top-end'
const PLACEMENT_BOTTOM = isRTL() ? 'bottom-end' : 'bottom-start'
const PLACEMENT_BOTTOMEND = isRTL() ? 'bottom-start' : 'bottom-end'
const PLACEMENT_RIGHT = isRTL() ? 'left-start' : 'right-start'
const PLACEMENT_LEFT = isRTL() ? 'right-start' : 'left-start'
const PLACEMENT_TOPCENTER = 'top'
const PLACEMENT_BOTTOMCENTER = 'bottom'
const Default: ComponentConfig = {
autoClose: true,
boundary: 'clippingParents',
display: 'dynamic',
offset: [0, 2],
popperConfig: null,
reference: 'toggle'
}
const DefaultType: ComponentConfigType = {
autoClose: '(boolean|string)',
boundary: '(string|element)',
display: 'string',
offset: '(array|string|function)',
popperConfig: '(null|object|function)',
reference: '(string|element|object)'
}
class Dropdown extends BaseComponent {
_popper: Popper.Instance | null
_parent: HTMLElement
_menu: HTMLElement
_inNavbar: boolean
constructor(element: HTMLElement | string, config?: Partial<ComponentConfig>) {
super(element, config)
this._popper = null
this._parent = this._element.parentNode as HTMLElement
this._menu = SelectorEngine.next(this._element, SELECTOR_MENU)[0] ||
SelectorEngine.prev(this._element, SELECTOR_MENU)[0] ||
SelectorEngine.findOne(SELECTOR_MENU, this._parent)!
this._inNavbar = this._detectNavbar()
}
static get Default(): ComponentConfig {
return Default
}
static get DefaultType(): ComponentConfigType {
return DefaultType
}
static get NAME(): string {
return NAME
}
toggle(): void {
this._isShown() ? this.hide() : this.show()
}
show(): void {
if (isDisabled(this._element) || this._isShown()) {
return
}
const relatedTarget = {
relatedTarget: this._element
}
const showEvent = EventHandler.trigger(this._element, EVENT_SHOW, relatedTarget)
if (showEvent.defaultPrevented) {
return
}
this._createPopper()
if ('ontouchstart' in document.documentElement && !this._parent.closest(SELECTOR_NAVBAR_NAV)) {
for (const element of [].concat(...(document.body.children as any))) {
EventHandler.on(element, 'mouseover', noop)
}
}
this._element.focus()
this._element.setAttribute('aria-expanded', 'true')
this._menu.classList.add(CLASS_NAME_SHOW)
this._element.classList.add(CLASS_NAME_SHOW)
EventHandler.trigger(this._element, EVENT_SHOWN, relatedTarget)
}
hide(): void {
if (isDisabled(this._element) || !this._isShown()) {
return
}
const relatedTarget = {
relatedTarget: this._element
}
this._completeHide(relatedTarget)
}
dispose(): void {
if (this._popper) {
this._popper.destroy()
}
super.dispose()
}
update(): void {
this._inNavbar = this._detectNavbar()
if (this._popper) {
this._popper.update()
}
}
_completeHide(relatedTarget: Record<string, any>): void {
const hideEvent = EventHandler.trigger(this._element, EVENT_HIDE, relatedTarget)
if (hideEvent.defaultPrevented) {
return
}
if ('ontouchstart' in document.documentElement) {
for (const element of [].concat(...(document.body.children as any))) {
EventHandler.off(element, 'mouseover', noop)
}
}
if (this._popper) {
this._popper.destroy()
}
this._menu.classList.remove(CLASS_NAME_SHOW)
this._element.classList.remove(CLASS_NAME_SHOW)
this._element.setAttribute('aria-expanded', 'false')
Manipulator.removeDataAttribute(this._menu, 'popper')
EventHandler.trigger(this._element, EVENT_HIDDEN, relatedTarget)
}
_getConfig(config: Partial<ComponentConfig>): ComponentConfig {
config = super._getConfig(config)
if (typeof config.reference === 'object' && !isElement(config.reference) &&
typeof (config.reference as any).getBoundingClientRect !== 'function'
) {
throw new TypeError(`${NAME.toUpperCase()}: Option "reference" provided type "object" without a required "getBoundingClientRect" method.`)
}
return config
}
_createPopper(): void {
if (typeof Popper === 'undefined') {
throw new TypeError('Bootstrap\'s dropdowns require Popper (https://popper.js.org/docs/v2/)')
}
let referenceElement: HTMLElement | Popper.VirtualElement = this._element
if (this._config.reference === 'parent') {
referenceElement = this._parent
} else if (isElement(this._config.reference)) {
referenceElement = getElement(this._config.reference as HTMLElement | string)!
} else if (typeof this._config.reference === 'object') {
referenceElement = this._config.reference as Popper.VirtualElement
}
const popperConfig = this._getPopperConfig()
this._popper = Popper.createPopper(referenceElement, this._menu, popperConfig)
}
_isShown(): boolean {
return this._menu.classList.contains(CLASS_NAME_SHOW)
}
_getPlacement(): string {
const parentDropdown = this._parent
if (parentDropdown.classList.contains(CLASS_NAME_DROPEND)) {
return PLACEMENT_RIGHT
}
if (parentDropdown.classList.contains(CLASS_NAME_DROPSTART)) {
return PLACEMENT_LEFT
}
if (parentDropdown.classList.contains(CLASS_NAME_DROPUP_CENTER)) {
return PLACEMENT_TOPCENTER
}
if (parentDropdown.classList.contains(CLASS_NAME_DROPDOWN_CENTER)) {
return PLACEMENT_BOTTOMCENTER
}
const isEnd = getComputedStyle(this._menu).getPropertyValue('--bs-position').trim() === 'end'
if (parentDropdown.classList.contains(CLASS_NAME_DROPUP)) {
return isEnd ? PLACEMENT_TOPEND : PLACEMENT_TOP
}
return isEnd ? PLACEMENT_BOTTOMEND : PLACEMENT_BOTTOM
}
_detectNavbar(): boolean {
return this._element.closest(SELECTOR_NAVBAR) !== null
}
_getOffset(): number[] | ((popperData: any) => number[]) {
const { offset } = this._config
if (typeof offset === 'string') {
return offset.split(',').map(value => Number.parseInt(value, 10))
}
if (typeof offset === 'function') {
return (popperData: any) => (offset as Function)(popperData, this._element)
}
return offset as number[]
}
_getPopperConfig(): Partial<Popper.Options> {
const defaultBsPopperConfig: Partial<Popper.Options> = {
placement: this._getPlacement() as Popper.Placement,
modifiers: [{
name: 'preventOverflow',
options: {
boundary: this._config.boundary
}
},
{
name: 'offset',
options: {
offset: this._getOffset()
}
}]
}
if (this._inNavbar || this._config.display === 'static') {
Manipulator.setDataAttribute(this._menu, 'popper', 'static')
defaultBsPopperConfig.modifiers = [{
name: 'applyStyles',
enabled: false
}]
}
const popperConfig = execute(this._config.popperConfig, [undefined, defaultBsPopperConfig])
return {
...defaultBsPopperConfig,
...(typeof popperConfig === 'object' && popperConfig !== null ? popperConfig : {})
}
}
_selectMenuItem({ key, target }: { key: string; target: HTMLElement }): void {
const items = SelectorEngine.find(SELECTOR_VISIBLE_ITEMS, this._menu).filter(element => isVisible(element))
if (!items.length) {
return
}
getNextActiveElement(items, target, key === ARROW_DOWN_KEY, !items.includes(target)).focus()
}
static clearMenus(event: Event & { button?: number; key?: string; composedPath?: () => EventTarget[] }): void {
if ((event as MouseEvent).button === RIGHT_MOUSE_BUTTON || (event.type === 'keyup' && (event as KeyboardEvent).key !== TAB_KEY)) {
return
}
const openToggles = SelectorEngine.find(SELECTOR_DATA_TOGGLE_SHOWN)
for (const toggle of openToggles) {
const context = Dropdown.getInstance(toggle) as Dropdown | null
if (!context || context._config.autoClose === false) {
continue
}
const composedPath = event.composedPath()
const isMenuTarget = composedPath.includes(context._menu)
if (
composedPath.includes(context._element) ||
(context._config.autoClose === 'inside' && !isMenuTarget) ||
(context._config.autoClose === 'outside' && isMenuTarget)
) {
continue
}
if (context._menu.contains(event.target as Node) && ((event.type === 'keyup' && (event as KeyboardEvent).key === TAB_KEY) || /input|select|option|textarea|form/i.test((event.target as HTMLElement).tagName))) {
continue
}
const relatedTarget: Record<string, any> = { relatedTarget: context._element }
if (event.type === 'click') {
relatedTarget.clickEvent = event
}
context._completeHide(relatedTarget)
}
}
static dataApiKeydownHandler(this: HTMLElement, event: KeyboardEvent): void {
const isInput = /input|textarea/i.test((event.target as HTMLElement).tagName)
const isEscapeEvent = event.key === ESCAPE_KEY
const isUpOrDownEvent = [ARROW_UP_KEY, ARROW_DOWN_KEY].includes(event.key)
if (!isUpOrDownEvent && !isEscapeEvent) {
return
}
if (isInput && !isEscapeEvent) {
return
}
event.preventDefault()
const getToggleButton = this.matches(SELECTOR_DATA_TOGGLE) ?
this :
(SelectorEngine.prev(this, SELECTOR_DATA_TOGGLE)[0] ||
SelectorEngine.next(this, SELECTOR_DATA_TOGGLE)[0] ||
SelectorEngine.findOne(SELECTOR_DATA_TOGGLE, (event as any).delegateTarget.parentNode))
const instance = Dropdown.getOrCreateInstance(getToggleButton!) as Dropdown
if (isUpOrDownEvent) {
event.stopPropagation()
instance.show()
instance._selectMenuItem(event as any)
return
}
if (instance._isShown()) {
event.stopPropagation()
instance.hide()
getToggleButton!.focus()
}
}
}
EventHandler.on(document, EVENT_KEYDOWN_DATA_API, SELECTOR_DATA_TOGGLE, Dropdown.dataApiKeydownHandler)
EventHandler.on(document, EVENT_KEYDOWN_DATA_API, SELECTOR_MENU, Dropdown.dataApiKeydownHandler)
EventHandler.on(document, EVENT_CLICK_DATA_API, Dropdown.clearMenus)
EventHandler.on(document, EVENT_KEYUP_DATA_API, Dropdown.clearMenus)
EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function (this: HTMLElement, event: Event) {
event.preventDefault()
;(Dropdown.getOrCreateInstance(this) as Dropdown).toggle()
})
export default Dropdown

View File

@@ -0,0 +1,358 @@
/**
* --------------------------------------------------------------------------
* Bootstrap modal.ts
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* --------------------------------------------------------------------------
*/
import BaseComponent from './base-component'
import EventHandler from './dom/event-handler'
import SelectorEngine from './dom/selector-engine'
import Backdrop from './util/backdrop'
import { enableDismissTrigger } from './util/component-functions'
import FocusTrap from './util/focustrap'
import {
isRTL, isVisible, reflow
} from './util/index'
import ScrollBarHelper from './util/scrollbar'
/**
* Constants
*/
const NAME = 'modal'
const DATA_KEY = 'bs.modal'
const EVENT_KEY = `.${DATA_KEY}`
const DATA_API_KEY = '.data-api'
const ESCAPE_KEY = 'Escape'
const EVENT_HIDE = `hide${EVENT_KEY}`
const EVENT_HIDE_PREVENTED = `hidePrevented${EVENT_KEY}`
const EVENT_HIDDEN = `hidden${EVENT_KEY}`
const EVENT_SHOW = `show${EVENT_KEY}`
const EVENT_SHOWN = `shown${EVENT_KEY}`
const EVENT_RESIZE = `resize${EVENT_KEY}`
const EVENT_CLICK_DISMISS = `click.dismiss${EVENT_KEY}`
const EVENT_MOUSEDOWN_DISMISS = `mousedown.dismiss${EVENT_KEY}`
const EVENT_KEYDOWN_DISMISS = `keydown.dismiss${EVENT_KEY}`
const EVENT_CLICK_DATA_API = `click${EVENT_KEY}${DATA_API_KEY}`
const CLASS_NAME_OPEN = 'modal-open'
const CLASS_NAME_FADE = 'fade'
const CLASS_NAME_SHOW = 'show'
const CLASS_NAME_STATIC = 'modal-static'
const OPEN_SELECTOR = '.modal.show'
const SELECTOR_DIALOG = '.modal-dialog'
const SELECTOR_MODAL_BODY = '.modal-body'
const SELECTOR_DATA_TOGGLE = '[data-bs-toggle="modal"], [data-tblr-toggle="modal"]'
interface ComponentConfig {
[key: string]: any
}
interface ComponentConfigType {
[key: string]: string
}
const Default: ComponentConfig = {
backdrop: true,
focus: true,
keyboard: true
}
const DefaultType: ComponentConfigType = {
backdrop: '(boolean|string)',
focus: 'boolean',
keyboard: 'boolean'
}
/**
* Class definition
*/
class Modal extends BaseComponent {
_dialog: HTMLElement | null
_backdrop: Backdrop
_focustrap: FocusTrap
_isShown: boolean
_isTransitioning: boolean
_scrollBar: ScrollBarHelper
constructor(element: HTMLElement | string, config?: Partial<ComponentConfig>) {
super(element, config)
this._dialog = SelectorEngine.findOne(SELECTOR_DIALOG, this._element)
this._backdrop = this._initializeBackDrop()
this._focustrap = this._initializeFocusTrap()
this._isShown = false
this._isTransitioning = false
this._scrollBar = new ScrollBarHelper()
this._addEventListeners()
}
static get Default(): ComponentConfig {
return Default
}
static get DefaultType(): ComponentConfigType {
return DefaultType
}
static get NAME(): string {
return NAME
}
toggle(relatedTarget?: HTMLElement): void {
return this._isShown ? this.hide() : this.show(relatedTarget)
}
show(relatedTarget?: HTMLElement): void {
if (this._isShown || this._isTransitioning) {
return
}
const showEvent = EventHandler.trigger(this._element, EVENT_SHOW, {
relatedTarget
})
if (showEvent.defaultPrevented) {
return
}
this._isShown = true
this._isTransitioning = true
this._scrollBar.hide()
document.body.classList.add(CLASS_NAME_OPEN)
this._adjustDialog()
this._backdrop.show(() => this._showElement(relatedTarget))
}
hide(): void {
if (!this._isShown || this._isTransitioning) {
return
}
const hideEvent = EventHandler.trigger(this._element, EVENT_HIDE)
if (hideEvent.defaultPrevented) {
return
}
this._isShown = false
this._isTransitioning = true
this._focustrap.deactivate()
this._element.classList.remove(CLASS_NAME_SHOW)
this._queueCallback(() => this._hideModal(), this._element, this._isAnimated())
}
dispose(): void {
EventHandler.off(window, EVENT_KEY)
EventHandler.off(this._dialog, EVENT_KEY)
this._backdrop.dispose()
this._focustrap.deactivate()
super.dispose()
}
handleUpdate(): void {
this._adjustDialog()
}
_initializeBackDrop(): Backdrop {
return new Backdrop({
isVisible: Boolean(this._config.backdrop),
isAnimated: this._isAnimated()
})
}
_initializeFocusTrap(): FocusTrap {
return new FocusTrap({
trapElement: this._element
})
}
_showElement(relatedTarget?: HTMLElement): void {
if (!document.body.contains(this._element)) {
document.body.append(this._element)
}
this._element.style.display = 'block'
this._element.removeAttribute('aria-hidden')
this._element.setAttribute('aria-modal', 'true')
this._element.setAttribute('role', 'dialog')
this._element.scrollTop = 0
const modalBody = SelectorEngine.findOne(SELECTOR_MODAL_BODY, this._dialog)
if (modalBody) {
modalBody.scrollTop = 0
}
reflow(this._element)
this._element.classList.add(CLASS_NAME_SHOW)
const transitionComplete = () => {
if (this._config.focus) {
this._focustrap.activate()
}
this._isTransitioning = false
EventHandler.trigger(this._element, EVENT_SHOWN, {
relatedTarget
})
}
this._queueCallback(transitionComplete, this._dialog!, this._isAnimated())
}
_addEventListeners(): void {
EventHandler.on(this._element, EVENT_KEYDOWN_DISMISS, (event: Event) => {
if ((event as KeyboardEvent).key !== ESCAPE_KEY) {
return
}
if (this._config.keyboard) {
this.hide()
return
}
this._triggerBackdropTransition()
})
EventHandler.on(window, EVENT_RESIZE, () => {
if (this._isShown && !this._isTransitioning) {
this._adjustDialog()
}
})
EventHandler.on(this._element, EVENT_MOUSEDOWN_DISMISS, (event: Event) => {
EventHandler.one(this._element, EVENT_CLICK_DISMISS, (event2: Event) => {
if (this._element !== event.target || this._element !== event2.target) {
return
}
if (this._config.backdrop === 'static') {
this._triggerBackdropTransition()
return
}
if (this._config.backdrop) {
this.hide()
}
})
})
}
_hideModal(): void {
this._element.style.display = 'none'
this._element.setAttribute('aria-hidden', 'true')
this._element.removeAttribute('aria-modal')
this._element.removeAttribute('role')
this._isTransitioning = false
this._backdrop.hide(() => {
document.body.classList.remove(CLASS_NAME_OPEN)
this._resetAdjustments()
this._scrollBar.reset()
EventHandler.trigger(this._element, EVENT_HIDDEN)
})
}
_isAnimated(): boolean {
return this._element.classList.contains(CLASS_NAME_FADE)
}
_triggerBackdropTransition(): void {
const hideEvent = EventHandler.trigger(this._element, EVENT_HIDE_PREVENTED)
if (hideEvent.defaultPrevented) {
return
}
const isModalOverflowing = this._element.scrollHeight > document.documentElement.clientHeight
const initialOverflowY = this._element.style.overflowY
if (initialOverflowY === 'hidden' || this._element.classList.contains(CLASS_NAME_STATIC)) {
return
}
if (!isModalOverflowing) {
this._element.style.overflowY = 'hidden'
}
this._element.classList.add(CLASS_NAME_STATIC)
this._queueCallback(() => {
this._element.classList.remove(CLASS_NAME_STATIC)
this._queueCallback(() => {
this._element.style.overflowY = initialOverflowY
}, this._dialog!)
}, this._dialog!)
this._element.focus()
}
_adjustDialog(): void {
const isModalOverflowing = this._element.scrollHeight > document.documentElement.clientHeight
const scrollbarWidth = this._scrollBar.getWidth()
const isBodyOverflowing = scrollbarWidth > 0
if (isBodyOverflowing && !isModalOverflowing) {
const property = isRTL() ? 'paddingLeft' : 'paddingRight'
this._element.style[property] = `${scrollbarWidth}px`
}
if (!isBodyOverflowing && isModalOverflowing) {
const property = isRTL() ? 'paddingRight' : 'paddingLeft'
this._element.style[property] = `${scrollbarWidth}px`
}
}
_resetAdjustments(): void {
this._element.style.paddingLeft = ''
this._element.style.paddingRight = ''
}
}
/**
* Data API implementation
*/
EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function (this: HTMLElement, event: Event) {
const target = SelectorEngine.getElementFromSelector(this)
if (['A', 'AREA'].includes(this.tagName)) {
event.preventDefault()
}
EventHandler.one(target, EVENT_SHOW, (showEvent: Event) => {
if (showEvent.defaultPrevented) {
return
}
EventHandler.one(target, EVENT_HIDDEN, () => {
if (isVisible(this)) {
this.focus()
}
})
})
const alreadyOpen = SelectorEngine.findOne(OPEN_SELECTOR)
if (alreadyOpen) {
;(Modal.getInstance(alreadyOpen) as Modal).hide()
}
const data = Modal.getOrCreateInstance(target) as Modal
data.toggle(this)
})
enableDismissTrigger(Modal)
export default Modal

View File

@@ -0,0 +1,261 @@
/**
* --------------------------------------------------------------------------
* Bootstrap offcanvas.ts
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* --------------------------------------------------------------------------
*/
import BaseComponent from './base-component'
import EventHandler from './dom/event-handler'
import SelectorEngine from './dom/selector-engine'
import Backdrop from './util/backdrop'
import { enableDismissTrigger } from './util/component-functions'
import FocusTrap from './util/focustrap'
import { isDisabled, isVisible } from './util/index'
import ScrollBarHelper from './util/scrollbar'
/**
* Constants
*/
const NAME = 'offcanvas'
const DATA_KEY = 'bs.offcanvas'
const EVENT_KEY = `.${DATA_KEY}`
const DATA_API_KEY = '.data-api'
const EVENT_LOAD_DATA_API = `load${EVENT_KEY}${DATA_API_KEY}`
const ESCAPE_KEY = 'Escape'
const CLASS_NAME_SHOW = 'show'
const CLASS_NAME_SHOWING = 'showing'
const CLASS_NAME_HIDING = 'hiding'
const CLASS_NAME_BACKDROP = 'offcanvas-backdrop'
const OPEN_SELECTOR = '.offcanvas.show'
const EVENT_SHOW = `show${EVENT_KEY}`
const EVENT_SHOWN = `shown${EVENT_KEY}`
const EVENT_HIDE = `hide${EVENT_KEY}`
const EVENT_HIDE_PREVENTED = `hidePrevented${EVENT_KEY}`
const EVENT_HIDDEN = `hidden${EVENT_KEY}`
const EVENT_RESIZE = `resize${EVENT_KEY}`
const EVENT_CLICK_DATA_API = `click${EVENT_KEY}${DATA_API_KEY}`
const EVENT_KEYDOWN_DISMISS = `keydown.dismiss${EVENT_KEY}`
const SELECTOR_DATA_TOGGLE = '[data-bs-toggle="offcanvas"], [data-tblr-toggle="offcanvas"]'
interface ComponentConfig {
[key: string]: any
}
interface ComponentConfigType {
[key: string]: string
}
const Default: ComponentConfig = {
backdrop: true,
keyboard: true,
scroll: false
}
const DefaultType: ComponentConfigType = {
backdrop: '(boolean|string)',
keyboard: 'boolean',
scroll: 'boolean'
}
/**
* Class definition
*/
class Offcanvas extends BaseComponent {
_isShown: boolean
_backdrop: Backdrop
_focustrap: FocusTrap
constructor(element: HTMLElement | string, config?: Partial<ComponentConfig>) {
super(element, config)
this._isShown = false
this._backdrop = this._initializeBackDrop()
this._focustrap = this._initializeFocusTrap()
this._addEventListeners()
}
static get Default(): ComponentConfig {
return Default
}
static get DefaultType(): ComponentConfigType {
return DefaultType
}
static get NAME(): string {
return NAME
}
toggle(relatedTarget?: HTMLElement): void {
return this._isShown ? this.hide() : this.show(relatedTarget)
}
show(relatedTarget?: HTMLElement): void {
if (this._isShown) {
return
}
const showEvent = EventHandler.trigger(this._element, EVENT_SHOW, { relatedTarget })
if (showEvent.defaultPrevented) {
return
}
this._isShown = true
this._backdrop.show()
if (!this._config.scroll) {
new ScrollBarHelper().hide()
}
this._element.setAttribute('aria-modal', 'true')
this._element.setAttribute('role', 'dialog')
this._element.classList.add(CLASS_NAME_SHOWING)
const completeCallBack = () => {
if (!this._config.scroll || this._config.backdrop) {
this._focustrap.activate()
}
this._element.classList.add(CLASS_NAME_SHOW)
this._element.classList.remove(CLASS_NAME_SHOWING)
EventHandler.trigger(this._element, EVENT_SHOWN, { relatedTarget })
}
this._queueCallback(completeCallBack, this._element, true)
}
hide(): void {
if (!this._isShown) {
return
}
const hideEvent = EventHandler.trigger(this._element, EVENT_HIDE)
if (hideEvent.defaultPrevented) {
return
}
this._focustrap.deactivate()
this._element.blur()
this._isShown = false
this._element.classList.add(CLASS_NAME_HIDING)
this._backdrop.hide()
const completeCallback = () => {
this._element.classList.remove(CLASS_NAME_SHOW, CLASS_NAME_HIDING)
this._element.removeAttribute('aria-modal')
this._element.removeAttribute('role')
if (!this._config.scroll) {
new ScrollBarHelper().reset()
}
EventHandler.trigger(this._element, EVENT_HIDDEN)
}
this._queueCallback(completeCallback, this._element, true)
}
dispose(): void {
this._backdrop.dispose()
this._focustrap.deactivate()
super.dispose()
}
_initializeBackDrop(): Backdrop {
const clickCallback = () => {
if (this._config.backdrop === 'static') {
EventHandler.trigger(this._element, EVENT_HIDE_PREVENTED)
return
}
this.hide()
}
const isBackdropVisible = Boolean(this._config.backdrop)
return new Backdrop({
className: CLASS_NAME_BACKDROP,
isVisible: isBackdropVisible,
isAnimated: true,
rootElement: this._element.parentNode as HTMLElement,
clickCallback: isBackdropVisible ? clickCallback : null
})
}
_initializeFocusTrap(): FocusTrap {
return new FocusTrap({
trapElement: this._element
})
}
_addEventListeners(): void {
EventHandler.on(this._element, EVENT_KEYDOWN_DISMISS, (event: Event) => {
if ((event as KeyboardEvent).key !== ESCAPE_KEY) {
return
}
if (this._config.keyboard) {
this.hide()
return
}
EventHandler.trigger(this._element, EVENT_HIDE_PREVENTED)
})
}
}
/**
* Data API implementation
*/
EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function (this: HTMLElement, event: Event) {
const target = SelectorEngine.getElementFromSelector(this)
if (['A', 'AREA'].includes(this.tagName)) {
event.preventDefault()
}
if (isDisabled(this)) {
return
}
EventHandler.one(target, EVENT_HIDDEN, () => {
if (isVisible(this)) {
this.focus()
}
})
const alreadyOpen = SelectorEngine.findOne(OPEN_SELECTOR)
if (alreadyOpen && alreadyOpen !== target) {
;(Offcanvas.getInstance(alreadyOpen) as Offcanvas).hide()
}
const data = Offcanvas.getOrCreateInstance(target) as Offcanvas
data.toggle(this)
})
EventHandler.on(window, EVENT_LOAD_DATA_API, () => {
for (const selector of SelectorEngine.find(OPEN_SELECTOR)) {
;(Offcanvas.getOrCreateInstance(selector) as Offcanvas).show()
}
})
EventHandler.on(window, EVENT_RESIZE, () => {
for (const element of SelectorEngine.find('[aria-modal][class*=show][class*=offcanvas-]')) {
if (getComputedStyle(element).position !== 'fixed') {
;(Offcanvas.getOrCreateInstance(element) as Offcanvas).hide()
}
}
})
enableDismissTrigger(Offcanvas)
export default Offcanvas

View File

@@ -0,0 +1,78 @@
/**
* --------------------------------------------------------------------------
* Bootstrap popover.ts
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* --------------------------------------------------------------------------
*/
import Tooltip from './tooltip'
/**
* Constants
*/
const NAME = 'popover'
const SELECTOR_TITLE = '.popover-header'
const SELECTOR_CONTENT = '.popover-body'
interface ComponentConfig {
[key: string]: any
}
interface ComponentConfigType {
[key: string]: string
}
const Default: ComponentConfig = {
...Tooltip.Default,
content: '',
offset: [0, 8],
placement: 'right',
template: '<div class="popover" role="tooltip">' +
'<div class="popover-arrow"></div>' +
'<h3 class="popover-header"></h3>' +
'<div class="popover-body"></div>' +
'</div>',
trigger: 'click'
}
const DefaultType: ComponentConfigType = {
...Tooltip.DefaultType,
content: '(null|string|element|function)'
}
/**
* Class definition
*/
class Popover extends Tooltip {
static get Default(): ComponentConfig {
return Default
}
static get DefaultType(): ComponentConfigType {
return DefaultType
}
static get NAME(): string {
return NAME
}
_isWithContent(): boolean {
return Boolean(this._getTitle() || this._getContent())
}
_getContentForTemplate(): Record<string, any> {
return {
[SELECTOR_TITLE]: this._getTitle(),
[SELECTOR_CONTENT]: this._getContent()
}
}
_getContent(): any {
return this._resolvePossibleFunction(this._config.content)
}
}
export default Popover

View File

@@ -0,0 +1,253 @@
/**
* --------------------------------------------------------------------------
* Bootstrap scrollspy.ts
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* --------------------------------------------------------------------------
*/
import BaseComponent from './base-component'
import EventHandler from './dom/event-handler'
import SelectorEngine from './dom/selector-engine'
import { getElement, isDisabled, isVisible } from './util/index'
import type { ComponentConfig, ComponentConfigType } from './types'
const NAME = 'scrollspy'
const DATA_KEY = 'bs.scrollspy'
const EVENT_KEY = `.${DATA_KEY}`
const DATA_API_KEY = '.data-api'
const EVENT_ACTIVATE = `activate${EVENT_KEY}`
const EVENT_CLICK = `click${EVENT_KEY}`
const EVENT_LOAD_DATA_API = `load${EVENT_KEY}${DATA_API_KEY}`
const CLASS_NAME_DROPDOWN_ITEM = 'dropdown-item'
const CLASS_NAME_ACTIVE = 'active'
const SELECTOR_DATA_SPY = '[data-bs-spy="scroll"], [data-tblr-spy="scroll"]'
const SELECTOR_TARGET_LINKS = '[href]'
const SELECTOR_NAV_LIST_GROUP = '.nav, .list-group'
const SELECTOR_NAV_LINKS = '.nav-link'
const SELECTOR_NAV_ITEMS = '.nav-item'
const SELECTOR_LIST_ITEMS = '.list-group-item'
const SELECTOR_LINK_ITEMS = `${SELECTOR_NAV_LINKS}, ${SELECTOR_NAV_ITEMS} > ${SELECTOR_NAV_LINKS}, ${SELECTOR_LIST_ITEMS}`
const SELECTOR_DROPDOWN = '.dropdown'
const SELECTOR_DROPDOWN_TOGGLE = '.dropdown-toggle'
const Default: ComponentConfig = {
offset: null,
rootMargin: '0px 0px -25%',
smoothScroll: false,
target: null,
threshold: [0.1, 0.5, 1]
}
const DefaultType: ComponentConfigType = {
offset: '(number|null)',
rootMargin: 'string',
smoothScroll: 'boolean',
target: 'element',
threshold: 'array'
}
class ScrollSpy extends BaseComponent {
_targetLinks: Map<string, HTMLElement>
_observableSections: Map<string, HTMLElement>
_rootElement: HTMLElement | null
_activeTarget: HTMLElement | null
_observer: IntersectionObserver | null
_previousScrollData: {
visibleEntryTop: number
parentScrollTop: number
}
constructor(element: HTMLElement | string, config?: Partial<ComponentConfig>) {
super(element, config)
this._targetLinks = new Map()
this._observableSections = new Map()
this._rootElement = getComputedStyle(this._element).overflowY === 'visible' ? null : this._element
this._activeTarget = null
this._observer = null
this._previousScrollData = {
visibleEntryTop: 0,
parentScrollTop: 0
}
this.refresh()
}
static get Default(): ComponentConfig {
return Default
}
static get DefaultType(): ComponentConfigType {
return DefaultType
}
static get NAME(): string {
return NAME
}
refresh(): void {
this._initializeTargetsAndObservables()
this._maybeEnableSmoothScroll()
if (this._observer) {
this._observer.disconnect()
} else {
this._observer = this._getNewObserver()
}
for (const section of this._observableSections.values()) {
this._observer.observe(section)
}
}
dispose(): void {
this._observer!.disconnect()
super.dispose()
}
_configAfterMerge(config: ComponentConfig): ComponentConfig {
config.target = getElement(config.target) || document.body
config.rootMargin = config.offset ? `${config.offset}px 0px -30%` : config.rootMargin
if (typeof config.threshold === 'string') {
config.threshold = config.threshold.split(',').map((value: string) => Number.parseFloat(value))
}
return config
}
_maybeEnableSmoothScroll(): void {
if (!this._config.smoothScroll) {
return
}
EventHandler.off(this._config.target as HTMLElement, EVENT_CLICK)
EventHandler.on(this._config.target as HTMLElement, EVENT_CLICK, SELECTOR_TARGET_LINKS, (event: Event) => {
const observableSection = this._observableSections.get((event.target as HTMLAnchorElement).hash)
if (observableSection) {
event.preventDefault()
const root = this._rootElement || window
const height = observableSection.offsetTop - this._element.offsetTop
if ('scrollTo' in root) {
root.scrollTo({ top: height, behavior: 'smooth' })
return
}
(root as HTMLElement).scrollTop = height
}
})
}
_getNewObserver(): IntersectionObserver {
const options: IntersectionObserverInit = {
root: this._rootElement,
threshold: this._config.threshold as number[],
rootMargin: this._config.rootMargin as string
}
return new IntersectionObserver(entries => this._observerCallback(entries), options)
}
_observerCallback(entries: IntersectionObserverEntry[]): void {
const targetElement = (entry: IntersectionObserverEntry) => this._targetLinks.get(`#${entry.target.id}`)
const activate = (entry: IntersectionObserverEntry) => {
this._previousScrollData.visibleEntryTop = (entry.target as HTMLElement).offsetTop
this._process(targetElement(entry)!)
}
const parentScrollTop = (this._rootElement || document.documentElement).scrollTop
const userScrollsDown = parentScrollTop >= this._previousScrollData.parentScrollTop
this._previousScrollData.parentScrollTop = parentScrollTop
for (const entry of entries) {
if (!entry.isIntersecting) {
this._activeTarget = null
this._clearActiveClass(targetElement(entry)!)
continue
}
const entryIsLowerThanPrevious = (entry.target as HTMLElement).offsetTop >= this._previousScrollData.visibleEntryTop
if (userScrollsDown && entryIsLowerThanPrevious) {
activate(entry)
if (!parentScrollTop) {
return
}
continue
}
if (!userScrollsDown && !entryIsLowerThanPrevious) {
activate(entry)
}
}
}
_initializeTargetsAndObservables(): void {
this._targetLinks = new Map()
this._observableSections = new Map()
const targetLinks = SelectorEngine.find(SELECTOR_TARGET_LINKS, this._config.target as HTMLElement)
for (const anchor of targetLinks) {
if (!(anchor as HTMLAnchorElement).hash || isDisabled(anchor)) {
continue
}
const observableSection = SelectorEngine.findOne(decodeURI((anchor as HTMLAnchorElement).hash), this._element)
if (isVisible(observableSection!)) {
this._targetLinks.set(decodeURI((anchor as HTMLAnchorElement).hash), anchor)
this._observableSections.set((anchor as HTMLAnchorElement).hash, observableSection!)
}
}
}
_process(target: HTMLElement): void {
if (this._activeTarget === target) {
return
}
this._clearActiveClass(this._config.target as HTMLElement)
this._activeTarget = target
target.classList.add(CLASS_NAME_ACTIVE)
this._activateParents(target)
EventHandler.trigger(this._element, EVENT_ACTIVATE, { relatedTarget: target })
}
_activateParents(target: HTMLElement): void {
if (target.classList.contains(CLASS_NAME_DROPDOWN_ITEM)) {
SelectorEngine.findOne(SELECTOR_DROPDOWN_TOGGLE, target.closest(SELECTOR_DROPDOWN)!)!
.classList.add(CLASS_NAME_ACTIVE)
return
}
for (const listGroup of SelectorEngine.parents(target, SELECTOR_NAV_LIST_GROUP)) {
for (const item of SelectorEngine.prev(listGroup, SELECTOR_LINK_ITEMS)) {
item.classList.add(CLASS_NAME_ACTIVE)
}
}
}
_clearActiveClass(parent: HTMLElement): void {
parent.classList.remove(CLASS_NAME_ACTIVE)
const activeNodes = SelectorEngine.find(`${SELECTOR_TARGET_LINKS}.${CLASS_NAME_ACTIVE}`, parent)
for (const node of activeNodes) {
node.classList.remove(CLASS_NAME_ACTIVE)
}
}
}
EventHandler.on(window, EVENT_LOAD_DATA_API, () => {
for (const spy of SelectorEngine.find(SELECTOR_DATA_SPY)) {
ScrollSpy.getOrCreateInstance(spy)
}
})
export default ScrollSpy

View File

@@ -0,0 +1,270 @@
/**
* --------------------------------------------------------------------------
* Bootstrap tab.ts
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* --------------------------------------------------------------------------
*/
import BaseComponent from './base-component'
import EventHandler from './dom/event-handler'
import SelectorEngine from './dom/selector-engine'
import { getNextActiveElement, isDisabled } from './util/index'
const NAME = 'tab'
const DATA_KEY = 'bs.tab'
const EVENT_KEY = `.${DATA_KEY}`
const EVENT_HIDE = `hide${EVENT_KEY}`
const EVENT_HIDDEN = `hidden${EVENT_KEY}`
const EVENT_SHOW = `show${EVENT_KEY}`
const EVENT_SHOWN = `shown${EVENT_KEY}`
const EVENT_CLICK_DATA_API = `click${EVENT_KEY}`
const EVENT_KEYDOWN = `keydown${EVENT_KEY}`
const EVENT_LOAD_DATA_API = `load${EVENT_KEY}`
const ARROW_LEFT_KEY = 'ArrowLeft'
const ARROW_RIGHT_KEY = 'ArrowRight'
const ARROW_UP_KEY = 'ArrowUp'
const ARROW_DOWN_KEY = 'ArrowDown'
const HOME_KEY = 'Home'
const END_KEY = 'End'
const CLASS_NAME_ACTIVE = 'active'
const CLASS_NAME_FADE = 'fade'
const CLASS_NAME_SHOW = 'show'
const CLASS_DROPDOWN = 'dropdown'
const SELECTOR_DROPDOWN_TOGGLE = '.dropdown-toggle'
const SELECTOR_DROPDOWN_MENU = '.dropdown-menu'
const NOT_SELECTOR_DROPDOWN_TOGGLE = `:not(${SELECTOR_DROPDOWN_TOGGLE})`
const SELECTOR_TAB_PANEL = '.list-group, .nav, [role="tablist"]'
const SELECTOR_OUTER = '.nav-item, .list-group-item'
const SELECTOR_INNER = `.nav-link${NOT_SELECTOR_DROPDOWN_TOGGLE}, .list-group-item${NOT_SELECTOR_DROPDOWN_TOGGLE}, [role="tab"]${NOT_SELECTOR_DROPDOWN_TOGGLE}`
const SELECTOR_DATA_TOGGLE = '[data-bs-toggle="tab"], [data-bs-toggle="pill"], [data-bs-toggle="list"], [data-tblr-toggle="tab"], [data-tblr-toggle="pill"], [data-tblr-toggle="list"]'
const SELECTOR_INNER_ELEM = `${SELECTOR_INNER}, ${SELECTOR_DATA_TOGGLE}`
const SELECTOR_DATA_TOGGLE_ACTIVE = `.${CLASS_NAME_ACTIVE}[data-bs-toggle="tab"], .${CLASS_NAME_ACTIVE}[data-bs-toggle="pill"], .${CLASS_NAME_ACTIVE}[data-bs-toggle="list"], .${CLASS_NAME_ACTIVE}[data-tblr-toggle="tab"], .${CLASS_NAME_ACTIVE}[data-tblr-toggle="pill"], .${CLASS_NAME_ACTIVE}[data-tblr-toggle="list"]`
class Tab extends BaseComponent {
_parent: HTMLElement | null
constructor(element: HTMLElement | string) {
super(element)
this._parent = this._element.closest(SELECTOR_TAB_PANEL)
if (!this._parent) {
return
}
this._setInitialAttributes(this._parent, this._getChildren())
EventHandler.on(this._element, EVENT_KEYDOWN, (event: KeyboardEvent) => this._keydown(event))
}
static get NAME(): string {
return NAME
}
show(): void {
const innerElem = this._element
if (this._elemIsActive(innerElem)) {
return
}
const active = this._getActiveElem()
const hideEvent = active ?
EventHandler.trigger(active, EVENT_HIDE, { relatedTarget: innerElem }) :
null
const showEvent = EventHandler.trigger(innerElem, EVENT_SHOW, { relatedTarget: active })
if (showEvent?.defaultPrevented || hideEvent?.defaultPrevented) {
return
}
this._deactivate(active, innerElem)
this._activate(innerElem, active)
}
_activate(element: HTMLElement | null, relatedElem?: HTMLElement | null): void {
if (!element) {
return
}
element.classList.add(CLASS_NAME_ACTIVE)
this._activate(SelectorEngine.getElementFromSelector(element))
const complete = () => {
if (element.getAttribute('role') !== 'tab') {
element.classList.add(CLASS_NAME_SHOW)
return
}
element.removeAttribute('tabindex')
element.setAttribute('aria-selected', 'true')
this._toggleDropDown(element, true)
EventHandler.trigger(element, EVENT_SHOWN, {
relatedTarget: relatedElem
})
}
this._queueCallback(complete, element, element.classList.contains(CLASS_NAME_FADE))
}
_deactivate(element: HTMLElement | null, relatedElem?: HTMLElement | null): void {
if (!element) {
return
}
element.classList.remove(CLASS_NAME_ACTIVE)
element.blur()
this._deactivate(SelectorEngine.getElementFromSelector(element))
const complete = () => {
if (element.getAttribute('role') !== 'tab') {
element.classList.remove(CLASS_NAME_SHOW)
return
}
element.setAttribute('aria-selected', 'false')
element.setAttribute('tabindex', '-1')
this._toggleDropDown(element, false)
EventHandler.trigger(element, EVENT_HIDDEN, { relatedTarget: relatedElem })
}
this._queueCallback(complete, element, element.classList.contains(CLASS_NAME_FADE))
}
_keydown(event: KeyboardEvent): void {
if (!([ARROW_LEFT_KEY, ARROW_RIGHT_KEY, ARROW_UP_KEY, ARROW_DOWN_KEY, HOME_KEY, END_KEY].includes(event.key))) {
return
}
event.stopPropagation()
event.preventDefault()
const children = this._getChildren().filter(element => !isDisabled(element))
let nextActiveElement: HTMLElement | undefined
if ([HOME_KEY, END_KEY].includes(event.key)) {
nextActiveElement = children[event.key === HOME_KEY ? 0 : children.length - 1]
} else {
const isNext = [ARROW_RIGHT_KEY, ARROW_DOWN_KEY].includes(event.key)
nextActiveElement = getNextActiveElement(children, event.target as HTMLElement, isNext, true) as HTMLElement
}
if (nextActiveElement) {
nextActiveElement.focus({ preventScroll: true })
;(Tab.getOrCreateInstance(nextActiveElement) as Tab).show()
}
}
_getChildren(): HTMLElement[] {
return SelectorEngine.find(SELECTOR_INNER_ELEM, this._parent!)
}
_getActiveElem(): HTMLElement | null {
return this._getChildren().find(child => this._elemIsActive(child)) || null
}
_setInitialAttributes(parent: HTMLElement, children: HTMLElement[]): void {
this._setAttributeIfNotExists(parent, 'role', 'tablist')
for (const child of children) {
this._setInitialAttributesOnChild(child)
}
}
_setInitialAttributesOnChild(child: HTMLElement): void {
child = this._getInnerElement(child)!
const isActive = this._elemIsActive(child)
const outerElem = this._getOuterElement(child)
child.setAttribute('aria-selected', String(isActive))
if (outerElem !== child) {
this._setAttributeIfNotExists(outerElem, 'role', 'presentation')
}
if (!isActive) {
child.setAttribute('tabindex', '-1')
}
this._setAttributeIfNotExists(child, 'role', 'tab')
this._setInitialAttributesOnTargetPanel(child)
}
_setInitialAttributesOnTargetPanel(child: HTMLElement): void {
const target = SelectorEngine.getElementFromSelector(child)
if (!target) {
return
}
this._setAttributeIfNotExists(target, 'role', 'tabpanel')
if (child.id) {
this._setAttributeIfNotExists(target, 'aria-labelledby', `${child.id}`)
}
}
_toggleDropDown(element: HTMLElement, open: boolean): void {
const outerElem = this._getOuterElement(element)
if (!outerElem.classList.contains(CLASS_DROPDOWN)) {
return
}
const toggle = (selector: string, className: string) => {
const el = SelectorEngine.findOne(selector, outerElem)
if (el) {
el.classList.toggle(className, open)
}
}
toggle(SELECTOR_DROPDOWN_TOGGLE, CLASS_NAME_ACTIVE)
toggle(SELECTOR_DROPDOWN_MENU, CLASS_NAME_SHOW)
outerElem.setAttribute('aria-expanded', String(open))
}
_setAttributeIfNotExists(element: HTMLElement, attribute: string, value: string): void {
if (!element.hasAttribute(attribute)) {
element.setAttribute(attribute, value)
}
}
_elemIsActive(elem: HTMLElement): boolean {
return elem.classList.contains(CLASS_NAME_ACTIVE)
}
_getInnerElement(elem: HTMLElement): HTMLElement | null {
return elem.matches(SELECTOR_INNER_ELEM) ? elem : SelectorEngine.findOne(SELECTOR_INNER_ELEM, elem)
}
_getOuterElement(elem: HTMLElement): HTMLElement {
return elem.closest(SELECTOR_OUTER) || elem
}
}
EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function (this: HTMLElement, event: Event) {
if (['A', 'AREA'].includes(this.tagName)) {
event.preventDefault()
}
if (isDisabled(this)) {
return
}
;(Tab.getOrCreateInstance(this) as Tab).show()
})
EventHandler.on(window, EVENT_LOAD_DATA_API, () => {
for (const element of SelectorEngine.find(SELECTOR_DATA_TOGGLE_ACTIVE)) {
Tab.getOrCreateInstance(element)
}
})
export default Tab

View File

@@ -0,0 +1,193 @@
/**
* --------------------------------------------------------------------------
* Bootstrap toast.ts
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* --------------------------------------------------------------------------
*/
import BaseComponent from './base-component'
import EventHandler from './dom/event-handler.js'
import { enableDismissTrigger } from './util/component-functions'
import { reflow } from './util/index.js'
import type { ComponentConfig, ComponentConfigType, ElementSelector } from './types'
const NAME = 'toast'
const DATA_KEY = 'bs.toast'
const EVENT_KEY = `.${DATA_KEY}`
const EVENT_MOUSEOVER = `mouseover${EVENT_KEY}`
const EVENT_MOUSEOUT = `mouseout${EVENT_KEY}`
const EVENT_FOCUSIN = `focusin${EVENT_KEY}`
const EVENT_FOCUSOUT = `focusout${EVENT_KEY}`
const EVENT_HIDE = `hide${EVENT_KEY}`
const EVENT_HIDDEN = `hidden${EVENT_KEY}`
const EVENT_SHOW = `show${EVENT_KEY}`
const EVENT_SHOWN = `shown${EVENT_KEY}`
const CLASS_NAME_FADE = 'fade'
const CLASS_NAME_HIDE = 'hide'
const CLASS_NAME_SHOW = 'show'
const CLASS_NAME_SHOWING = 'showing'
const DefaultType: ComponentConfigType = {
animation: 'boolean',
autohide: 'boolean',
delay: 'number'
}
const Default: ComponentConfig = {
animation: true,
autohide: true,
delay: 5000
}
class Toast extends BaseComponent {
_timeout: ReturnType<typeof setTimeout> | null
_hasMouseInteraction: boolean
_hasKeyboardInteraction: boolean
constructor(element: ElementSelector, config?: ComponentConfig) {
super(element, config)
this._timeout = null
this._hasMouseInteraction = false
this._hasKeyboardInteraction = false
this._setListeners()
}
static get Default(): ComponentConfig {
return Default
}
static get DefaultType(): ComponentConfigType {
return DefaultType
}
static get NAME(): string {
return NAME
}
show(): void {
const showEvent = EventHandler.trigger(this._element, EVENT_SHOW)
if (showEvent?.defaultPrevented) {
return
}
this._clearTimeout()
if (this._config.animation) {
this._element.classList.add(CLASS_NAME_FADE)
}
const complete = () => {
this._element.classList.remove(CLASS_NAME_SHOWING)
EventHandler.trigger(this._element, EVENT_SHOWN)
this._maybeScheduleHide()
}
this._element.classList.remove(CLASS_NAME_HIDE)
reflow(this._element)
this._element.classList.add(CLASS_NAME_SHOW, CLASS_NAME_SHOWING)
this._queueCallback(complete, this._element, this._config.animation)
}
hide(): void {
if (!this.isShown()) {
return
}
const hideEvent = EventHandler.trigger(this._element, EVENT_HIDE)
if (hideEvent?.defaultPrevented) {
return
}
const complete = () => {
this._element.classList.add(CLASS_NAME_HIDE)
this._element.classList.remove(CLASS_NAME_SHOWING, CLASS_NAME_SHOW)
EventHandler.trigger(this._element, EVENT_HIDDEN)
}
this._element.classList.add(CLASS_NAME_SHOWING)
this._queueCallback(complete, this._element, this._config.animation)
}
dispose(): void {
this._clearTimeout()
if (this.isShown()) {
this._element.classList.remove(CLASS_NAME_SHOW)
}
super.dispose()
}
isShown(): boolean {
return this._element.classList.contains(CLASS_NAME_SHOW)
}
_maybeScheduleHide(): void {
if (!this._config.autohide) {
return
}
if (this._hasMouseInteraction || this._hasKeyboardInteraction) {
return
}
this._timeout = setTimeout(() => {
this.hide()
}, this._config.delay)
}
_onInteraction(event: Event, isInteracting: boolean): void {
switch (event.type) {
case 'mouseover':
case 'mouseout': {
this._hasMouseInteraction = isInteracting
break
}
case 'focusin':
case 'focusout': {
this._hasKeyboardInteraction = isInteracting
break
}
default: {
break
}
}
if (isInteracting) {
this._clearTimeout()
return
}
const nextElement = (event as FocusEvent).relatedTarget as Node | null
if (this._element === nextElement || this._element.contains(nextElement)) {
return
}
this._maybeScheduleHide()
}
_setListeners(): void {
EventHandler.on(this._element, EVENT_MOUSEOVER, (event: Event) => this._onInteraction(event, true))
EventHandler.on(this._element, EVENT_MOUSEOUT, (event: Event) => this._onInteraction(event, false))
EventHandler.on(this._element, EVENT_FOCUSIN, (event: Event) => this._onInteraction(event, true))
EventHandler.on(this._element, EVENT_FOCUSOUT, (event: Event) => this._onInteraction(event, false))
}
_clearTimeout(): void {
clearTimeout(this._timeout!)
this._timeout = null
}
}
enableDismissTrigger(Toast)
export default Toast

View File

@@ -0,0 +1,610 @@
/**
* --------------------------------------------------------------------------
* Bootstrap tooltip.ts
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* --------------------------------------------------------------------------
*/
import * as Popper from '@popperjs/core'
import BaseComponent from './base-component'
import EventHandler from './dom/event-handler'
import Manipulator from './dom/manipulator'
import { execute, findShadowRoot, getElement, getUID, isRTL, noop } from './util/index'
import { DefaultAllowlist } from './util/sanitizer'
import TemplateFactory from './util/template-factory'
/**
* Constants
*/
const NAME = 'tooltip'
const DISALLOWED_ATTRIBUTES = new Set(['sanitize', 'allowList', 'sanitizeFn'])
const CLASS_NAME_FADE = 'fade'
const CLASS_NAME_MODAL = 'modal'
const CLASS_NAME_SHOW = 'show'
const SELECTOR_TOOLTIP_INNER = '.tooltip-inner'
const SELECTOR_MODAL = `.${CLASS_NAME_MODAL}`
const EVENT_MODAL_HIDE = 'hide.bs.modal'
const TRIGGER_HOVER = 'hover'
const TRIGGER_FOCUS = 'focus'
const TRIGGER_CLICK = 'click'
const TRIGGER_MANUAL = 'manual'
const EVENT_HIDE = 'hide'
const EVENT_HIDDEN = 'hidden'
const EVENT_SHOW = 'show'
const EVENT_SHOWN = 'shown'
const EVENT_INSERTED = 'inserted'
const EVENT_CLICK = 'click'
const EVENT_FOCUSIN = 'focusin'
const EVENT_FOCUSOUT = 'focusout'
const EVENT_MOUSEENTER = 'mouseenter'
const EVENT_MOUSELEAVE = 'mouseleave'
interface ComponentConfig {
[key: string]: any
}
interface ComponentConfigType {
[key: string]: string
}
const AttachmentMap: Record<string, string> = {
AUTO: 'auto',
TOP: 'top',
RIGHT: isRTL() ? 'left' : 'right',
BOTTOM: 'bottom',
LEFT: isRTL() ? 'right' : 'left'
}
const Default: ComponentConfig = {
allowList: DefaultAllowlist,
animation: true,
boundary: 'clippingParents',
container: false,
customClass: '',
delay: 0,
fallbackPlacements: ['top', 'right', 'bottom', 'left'],
html: false,
offset: [0, 6],
placement: 'top',
popperConfig: null,
sanitize: true,
sanitizeFn: null,
selector: false,
template: '<div class="tooltip" role="tooltip">' +
'<div class="tooltip-arrow"></div>' +
'<div class="tooltip-inner"></div>' +
'</div>',
title: '',
trigger: 'hover focus'
}
const DefaultType: ComponentConfigType = {
allowList: 'object',
animation: 'boolean',
boundary: '(string|element)',
container: '(string|element|boolean)',
customClass: '(string|function)',
delay: '(number|object)',
fallbackPlacements: 'array',
html: 'boolean',
offset: '(array|string|function)',
placement: '(string|function)',
popperConfig: '(null|object|function)',
sanitize: 'boolean',
sanitizeFn: '(null|function)',
selector: '(string|boolean)',
template: 'string',
title: '(string|element|function)',
trigger: 'string'
}
/**
* Class definition
*/
class Tooltip extends BaseComponent {
_isEnabled: boolean
_timeout: ReturnType<typeof setTimeout> | number
_isHovered: boolean | null
_activeTrigger: Record<string, boolean>
_popper: Popper.Instance | null
_templateFactory: TemplateFactory | null
_newContent: Record<string, any> | null
tip: HTMLElement | null
_hideModalHandler: (() => void) | null
constructor(element: HTMLElement | string, config?: Partial<ComponentConfig>) {
if (typeof Popper === 'undefined') {
throw new TypeError('Bootstrap\'s tooltips require Popper (https://popper.js.org/docs/v2/)')
}
super(element, config)
this._isEnabled = true
this._timeout = 0
this._isHovered = null
this._activeTrigger = {}
this._popper = null
this._templateFactory = null
this._newContent = null
this.tip = null
this._hideModalHandler = null
this._setListeners()
if (!this._config.selector) {
this._fixTitle()
}
}
static get Default(): ComponentConfig {
return Default
}
static get DefaultType(): ComponentConfigType {
return DefaultType
}
static get NAME(): string {
return NAME
}
enable(): void {
this._isEnabled = true
}
disable(): void {
this._isEnabled = false
}
toggleEnabled(): void {
this._isEnabled = !this._isEnabled
}
toggle(): void {
if (!this._isEnabled) {
return
}
if (this._isShown()) {
this._leave()
return
}
this._enter()
}
dispose(): void {
clearTimeout(this._timeout)
EventHandler.off(this._element.closest(SELECTOR_MODAL), EVENT_MODAL_HIDE, this._hideModalHandler)
if (this._element.getAttribute('data-bs-original-title') || this._element.getAttribute('data-tblr-original-title')) {
this._element.setAttribute('title',
this._element.getAttribute('data-bs-original-title') ||
this._element.getAttribute('data-tblr-original-title') || '')
}
this._disposePopper()
super.dispose()
}
show(): void {
if (this._element.style.display === 'none') {
throw new Error('Please use show on visible elements')
}
if (!(this._isWithContent() && this._isEnabled)) {
return
}
const showEvent = EventHandler.trigger(this._element, (this.constructor as typeof Tooltip).eventName(EVENT_SHOW))
const shadowRoot = findShadowRoot(this._element)
const isInTheDom = (shadowRoot || this._element.ownerDocument.documentElement).contains(this._element)
if (showEvent.defaultPrevented || !isInTheDom) {
return
}
this._disposePopper()
const tip = this._getTipElement()
this._element.setAttribute('aria-describedby', tip!.getAttribute('id')!)
const { container } = this._config
if (!this._element.ownerDocument.documentElement.contains(this.tip)) {
container.append(tip)
EventHandler.trigger(this._element, (this.constructor as typeof Tooltip).eventName(EVENT_INSERTED))
}
this._popper = this._createPopper(tip!)
tip!.classList.add(CLASS_NAME_SHOW)
if ('ontouchstart' in document.documentElement) {
for (const element of [].concat(...(document.body.children as any))) {
EventHandler.on(element, 'mouseover', noop)
}
}
const complete = () => {
EventHandler.trigger(this._element, (this.constructor as typeof Tooltip).eventName(EVENT_SHOWN))
if (this._isHovered === false) {
this._leave()
}
this._isHovered = false
}
this._queueCallback(complete, this.tip!, this._isAnimated())
}
hide(): void {
if (!this._isShown()) {
return
}
const hideEvent = EventHandler.trigger(this._element, (this.constructor as typeof Tooltip).eventName(EVENT_HIDE))
if (hideEvent.defaultPrevented) {
return
}
const tip = this._getTipElement()
tip!.classList.remove(CLASS_NAME_SHOW)
if ('ontouchstart' in document.documentElement) {
for (const element of [].concat(...(document.body.children as any))) {
EventHandler.off(element, 'mouseover', noop)
}
}
this._activeTrigger[TRIGGER_CLICK] = false
this._activeTrigger[TRIGGER_FOCUS] = false
this._activeTrigger[TRIGGER_HOVER] = false
this._isHovered = null
const complete = () => {
if (this._isWithActiveTrigger()) {
return
}
if (!this._isHovered) {
this._disposePopper()
}
this._element.removeAttribute('aria-describedby')
EventHandler.trigger(this._element, (this.constructor as typeof Tooltip).eventName(EVENT_HIDDEN))
}
this._queueCallback(complete, this.tip!, this._isAnimated())
}
update(): void {
if (this._popper) {
this._popper.update()
}
}
_isWithContent(): boolean {
return Boolean(this._getTitle())
}
_getTipElement(): HTMLElement | null {
if (!this.tip) {
this.tip = this._createTipElement(this._newContent || this._getContentForTemplate())
}
return this.tip
}
_createTipElement(content: Record<string, any>): HTMLElement | null {
const tip = this._getTemplateFactory(content).toHtml() as HTMLElement
if (!tip) {
return null
}
tip.classList.remove(CLASS_NAME_FADE, CLASS_NAME_SHOW)
tip.classList.add(`bs-${(this.constructor as typeof Tooltip).NAME}-auto`)
const tipId = getUID((this.constructor as typeof Tooltip).NAME).toString()
tip.setAttribute('id', tipId)
if (this._isAnimated()) {
tip.classList.add(CLASS_NAME_FADE)
}
return tip
}
setContent(content: Record<string, any>): void {
this._newContent = content
if (this._isShown()) {
this._disposePopper()
this.show()
}
}
_getTemplateFactory(content: Record<string, any>): TemplateFactory {
if (this._templateFactory) {
this._templateFactory.changeContent(content)
} else {
this._templateFactory = new TemplateFactory({
...this._config,
content,
extraClass: this._resolvePossibleFunction(this._config.customClass)
})
}
return this._templateFactory
}
_getContentForTemplate(): Record<string, any> {
return {
[SELECTOR_TOOLTIP_INNER]: this._getTitle()
}
}
_getTitle(): string {
return this._resolvePossibleFunction(this._config.title) ||
this._element.getAttribute('data-bs-original-title') ||
this._element.getAttribute('data-tblr-original-title') || ''
}
_initializeOnDelegatedTarget(event: Event & { delegateTarget?: HTMLElement }): Tooltip {
return (this.constructor as typeof Tooltip).getOrCreateInstance(event.delegateTarget!, this._getDelegateConfig()) as Tooltip
}
_isAnimated(): boolean {
return this._config.animation || (this.tip !== null && this.tip.classList.contains(CLASS_NAME_FADE))
}
_isShown(): boolean {
return this.tip !== null && this.tip.classList.contains(CLASS_NAME_SHOW)
}
_createPopper(tip: HTMLElement): Popper.Instance {
const placement = execute(this._config.placement, [this, tip, this._element]) as string
const attachment = AttachmentMap[placement.toUpperCase()]
return Popper.createPopper(this._element, tip, this._getPopperConfig(attachment))
}
_getOffset(): number[] | ((popperData: any) => number[]) {
const { offset } = this._config
if (typeof offset === 'string') {
return offset.split(',').map((value: string) => Number.parseInt(value, 10))
}
if (typeof offset === 'function') {
return (popperData: any) => (offset as Function)(popperData, this._element)
}
return offset as number[]
}
_resolvePossibleFunction(arg: any): any {
return execute(arg, [this._element, this._element])
}
_getPopperConfig(attachment: string): Partial<Popper.Options> {
const defaultBsPopperConfig: Partial<Popper.Options> = {
placement: attachment as Popper.Placement,
modifiers: [
{
name: 'flip',
options: {
fallbackPlacements: this._config.fallbackPlacements
}
},
{
name: 'offset',
options: {
offset: this._getOffset()
}
},
{
name: 'preventOverflow',
options: {
boundary: this._config.boundary
}
},
{
name: 'arrow',
options: {
element: `.${(this.constructor as typeof Tooltip).NAME}-arrow`
}
},
{
name: 'preSetPlacement',
enabled: true,
phase: 'beforeMain',
fn: (data: any) => {
this._getTipElement()!.setAttribute('data-popper-placement', data.state.placement)
}
}
]
}
const popperConfig = execute(this._config.popperConfig, [undefined, defaultBsPopperConfig])
return {
...defaultBsPopperConfig,
...(typeof popperConfig === 'object' && popperConfig !== null ? popperConfig : {})
}
}
_setListeners(): void {
const triggers = this._config.trigger.split(' ')
for (const trigger of triggers) {
if (trigger === 'click') {
EventHandler.on(this._element, (this.constructor as typeof Tooltip).eventName(EVENT_CLICK), this._config.selector, (event: Event) => {
const context = this._initializeOnDelegatedTarget(event)
context._activeTrigger[TRIGGER_CLICK] = !(context._isShown() && context._activeTrigger[TRIGGER_CLICK])
context.toggle()
})
} else if (trigger !== TRIGGER_MANUAL) {
const eventIn = trigger === TRIGGER_HOVER ?
(this.constructor as typeof Tooltip).eventName(EVENT_MOUSEENTER) :
(this.constructor as typeof Tooltip).eventName(EVENT_FOCUSIN)
const eventOut = trigger === TRIGGER_HOVER ?
(this.constructor as typeof Tooltip).eventName(EVENT_MOUSELEAVE) :
(this.constructor as typeof Tooltip).eventName(EVENT_FOCUSOUT)
EventHandler.on(this._element, eventIn, this._config.selector, (event: Event) => {
const context = this._initializeOnDelegatedTarget(event)
context._activeTrigger[event.type === 'focusin' ? TRIGGER_FOCUS : TRIGGER_HOVER] = true
context._enter()
})
EventHandler.on(this._element, eventOut, this._config.selector, (event: Event & { relatedTarget?: HTMLElement }) => {
const context = this._initializeOnDelegatedTarget(event)
context._activeTrigger[event.type === 'focusout' ? TRIGGER_FOCUS : TRIGGER_HOVER] =
context._element.contains(event.relatedTarget as Node)
context._leave()
})
}
}
this._hideModalHandler = () => {
if (this._element) {
this.hide()
}
}
EventHandler.on(this._element.closest(SELECTOR_MODAL), EVENT_MODAL_HIDE, this._hideModalHandler)
}
_fixTitle(): void {
const title = this._element.getAttribute('title')
if (!title) {
return
}
if (!this._element.getAttribute('aria-label') && !this._element.textContent!.trim()) {
this._element.setAttribute('aria-label', title)
}
this._element.setAttribute('data-bs-original-title', title)
this._element.removeAttribute('title')
}
_enter(): void {
if (this._isShown() || this._isHovered) {
this._isHovered = true
return
}
this._isHovered = true
this._setTimeout(() => {
if (this._isHovered) {
this.show()
}
}, this._config.delay.show)
}
_leave(): void {
if (this._isWithActiveTrigger()) {
return
}
this._isHovered = false
this._setTimeout(() => {
if (!this._isHovered) {
this.hide()
}
}, this._config.delay.hide)
}
_setTimeout(handler: () => void, timeout: number): void {
clearTimeout(this._timeout)
this._timeout = setTimeout(handler, timeout)
}
_isWithActiveTrigger(): boolean {
return Object.values(this._activeTrigger).includes(true)
}
_getConfig(config: Partial<ComponentConfig>): ComponentConfig {
const dataAttributes = Manipulator.getDataAttributes(this._element)
for (const dataAttribute of Object.keys(dataAttributes)) {
if (DISALLOWED_ATTRIBUTES.has(dataAttribute)) {
delete dataAttributes[dataAttribute]
}
}
config = {
...dataAttributes,
...(typeof config === 'object' && config ? config : {})
}
config = this._mergeConfigObj(config)
config = this._configAfterMerge(config)
this._typeCheckConfig(config)
return config
}
_configAfterMerge(config: ComponentConfig): ComponentConfig {
config.container = config.container === false ? document.body : getElement(config.container)
if (typeof config.delay === 'number') {
config.delay = {
show: config.delay,
hide: config.delay
}
}
if (typeof config.title === 'number') {
config.title = config.title.toString()
}
if (typeof config.content === 'number') {
config.content = config.content.toString()
}
return config
}
_getDelegateConfig(): Partial<ComponentConfig> {
const config: Partial<ComponentConfig> = {}
for (const [key, value] of Object.entries(this._config)) {
if ((this.constructor as typeof Tooltip).Default[key] !== value) {
config[key] = value
}
}
config.selector = false
config.trigger = 'manual'
return config
}
_disposePopper(): void {
if (this._popper) {
this._popper.destroy()
this._popper = null
}
if (this.tip) {
this.tip.remove()
this.tip = null
}
}
}
export default Tooltip

View File

@@ -0,0 +1,27 @@
/**
* --------------------------------------------------------------------------
* Bootstrap types.ts
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* --------------------------------------------------------------------------
*/
export type ComponentConfig = Record<string, any>
export type ComponentConfigType = Record<string, string>
export type ElementSelector = string | HTMLElement
export interface ConfigStatic {
NAME: string
Default: ComponentConfig
DefaultType: ComponentConfigType
}
export interface BaseComponentStatic extends ConfigStatic {
DATA_KEY: string
EVENT_KEY: string
}
export type AllowList = Record<string, (string | RegExp)[]>
export type SanitizeFn = (unsafeHtml: string) => string

View File

@@ -0,0 +1,152 @@
/**
* --------------------------------------------------------------------------
* Bootstrap util/backdrop.ts
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* --------------------------------------------------------------------------
*/
import EventHandler from '../dom/event-handler.js'
import Config from './config'
import {
execute, executeAfterTransition, getElement, reflow
} from './index'
import type { ComponentConfig, ComponentConfigType } from '../types'
const NAME = 'backdrop'
const CLASS_NAME_FADE = 'fade'
const CLASS_NAME_SHOW = 'show'
const EVENT_MOUSEDOWN = `mousedown.bs.${NAME}`
interface BackdropConfig {
className: string
clickCallback: (() => void) | null
isAnimated: boolean
isVisible: boolean
rootElement: HTMLElement | string
}
const Default: BackdropConfig = {
className: 'modal-backdrop',
clickCallback: null,
isAnimated: false,
isVisible: true,
rootElement: 'body'
}
const DefaultType: ComponentConfigType = {
className: 'string',
clickCallback: '(function|null)',
isAnimated: 'boolean',
isVisible: 'boolean',
rootElement: '(element|string)'
}
class Backdrop extends Config {
declare _config: BackdropConfig & ComponentConfig
_isAppended: boolean
_element: HTMLElement | null
constructor(config?: ComponentConfig) {
super()
this._config = this._getConfig(config) as BackdropConfig & ComponentConfig
this._isAppended = false
this._element = null
}
static get Default(): ComponentConfig {
return Default
}
static get DefaultType(): ComponentConfigType {
return DefaultType
}
static get NAME(): string {
return NAME
}
show(callback?: () => void): void {
if (!this._config.isVisible) {
execute(callback)
return
}
this._append()
const element = this._getElement()
if (this._config.isAnimated) {
reflow(element)
}
element.classList.add(CLASS_NAME_SHOW)
this._emulateAnimation(() => {
execute(callback)
})
}
hide(callback?: () => void): void {
if (!this._config.isVisible) {
execute(callback)
return
}
this._getElement().classList.remove(CLASS_NAME_SHOW)
this._emulateAnimation(() => {
this.dispose()
execute(callback)
})
}
dispose(): void {
if (!this._isAppended) {
return
}
EventHandler.off(this._element, EVENT_MOUSEDOWN)
this._element!.remove()
this._isAppended = false
}
_getElement(): HTMLElement {
if (!this._element) {
const backdrop = document.createElement('div')
backdrop.className = this._config.className
if (this._config.isAnimated) {
backdrop.classList.add(CLASS_NAME_FADE)
}
this._element = backdrop
}
return this._element
}
_configAfterMerge(config: ComponentConfig): ComponentConfig {
config.rootElement = getElement(config.rootElement)
return config
}
_append(): void {
if (this._isAppended) {
return
}
const element = this._getElement()
;(this._config.rootElement as HTMLElement).append(element)
EventHandler.on(element, EVENT_MOUSEDOWN, () => {
execute(this._config.clickCallback)
})
this._isAppended = true
}
_emulateAnimation(callback: () => void): void {
executeAfterTransition(callback, this._getElement(), this._config.isAnimated)
}
}
export default Backdrop

View File

@@ -0,0 +1,41 @@
/**
* --------------------------------------------------------------------------
* Bootstrap util/component-functions.ts
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* --------------------------------------------------------------------------
*/
import EventHandler from '../dom/event-handler.js'
import SelectorEngine from '../dom/selector-engine.js'
import { isDisabled } from './index'
interface DismissibleComponent {
EVENT_KEY: string
NAME: string
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getOrCreateInstance(element: HTMLElement | string | null): any
}
const enableDismissTrigger = (component: DismissibleComponent, method = 'hide'): void => {
const clickEvent = `click.dismiss${component.EVENT_KEY}`
const name = component.NAME
EventHandler.on(document, clickEvent, `[data-bs-dismiss="${name}"], [data-tblr-dismiss="${name}"]`, function (this: HTMLElement, event: Event) {
if (['A', 'AREA'].includes(this.tagName)) {
event.preventDefault()
}
if (isDisabled(this)) {
return
}
const target = SelectorEngine.getElementFromSelector(this) || this.closest(`.${name}`)
const instance = component.getOrCreateInstance(target)
instance[method]()
})
}
export {
enableDismissTrigger
}

View File

@@ -0,0 +1,65 @@
/**
* --------------------------------------------------------------------------
* Bootstrap util/config.ts
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* --------------------------------------------------------------------------
*/
import Manipulator from '../dom/manipulator.js'
import { isElement, toType } from './index.js'
import type { ComponentConfig, ComponentConfigType, ConfigStatic } from '../types'
class Config {
static get Default(): ComponentConfig {
return {}
}
static get DefaultType(): ComponentConfigType {
return {}
}
static get NAME(): string {
throw new Error('You have to implement the static method "NAME", for each component!')
}
_getConfig(config?: ComponentConfig): ComponentConfig {
config = this._mergeConfigObj(config)
config = this._configAfterMerge(config)
this._typeCheckConfig(config)
return config
}
_configAfterMerge(config: ComponentConfig): ComponentConfig {
return config
}
_mergeConfigObj(config?: ComponentConfig, element?: HTMLElement): ComponentConfig {
const jsonConfig = isElement(element) ? Manipulator.getDataAttribute(element!, 'config') : {}
const ctor = this.constructor as unknown as ConfigStatic
return {
...ctor.Default,
...(typeof jsonConfig === 'object' ? jsonConfig : {}),
...(isElement(element) ? Manipulator.getDataAttributes(element!) : {}),
...(typeof config === 'object' ? config : {})
}
}
_typeCheckConfig(config: ComponentConfig, configTypes?: ComponentConfigType): void {
const ctor = this.constructor as unknown as ConfigStatic
const types = configTypes || ctor.DefaultType
for (const [property, expectedTypes] of Object.entries(types)) {
const value = config[property]
const valueType = isElement(value) ? 'element' : toType(value)
if (!new RegExp(expectedTypes).test(valueType)) {
throw new TypeError(
`${ctor.NAME.toUpperCase()}: Option "${property}" provided type "${valueType}" but expected type "${expectedTypes}".`
)
}
}
}
}
export default Config

View File

@@ -0,0 +1,114 @@
/**
* --------------------------------------------------------------------------
* Bootstrap util/focustrap.ts
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* --------------------------------------------------------------------------
*/
import EventHandler from '../dom/event-handler.js'
import SelectorEngine from '../dom/selector-engine.js'
import Config from './config'
import type { ComponentConfig, ComponentConfigType } from '../types'
const NAME = 'focustrap'
const DATA_KEY = 'bs.focustrap'
const EVENT_KEY = `.${DATA_KEY}`
const EVENT_FOCUSIN = `focusin${EVENT_KEY}`
const EVENT_KEYDOWN_TAB = `keydown.tab${EVENT_KEY}`
const TAB_KEY = 'Tab'
const TAB_NAV_FORWARD = 'forward'
const TAB_NAV_BACKWARD = 'backward'
interface FocusTrapConfig {
autofocus: boolean
trapElement: HTMLElement | null
}
const Default: FocusTrapConfig = {
autofocus: true,
trapElement: null
}
const DefaultType: ComponentConfigType = {
autofocus: 'boolean',
trapElement: 'element'
}
class FocusTrap extends Config {
declare _config: FocusTrapConfig & ComponentConfig
_isActive: boolean
_lastTabNavDirection: string | null
constructor(config?: ComponentConfig) {
super()
this._config = this._getConfig(config) as FocusTrapConfig & ComponentConfig
this._isActive = false
this._lastTabNavDirection = null
}
static get Default(): ComponentConfig {
return Default
}
static get DefaultType(): ComponentConfigType {
return DefaultType
}
static get NAME(): string {
return NAME
}
activate(): void {
if (this._isActive) {
return
}
if (this._config.autofocus) {
this._config.trapElement!.focus()
}
EventHandler.off(document, EVENT_KEY)
EventHandler.on(document, EVENT_FOCUSIN, (event: FocusEvent) => this._handleFocusin(event))
EventHandler.on(document, EVENT_KEYDOWN_TAB, (event: KeyboardEvent) => this._handleKeydown(event))
this._isActive = true
}
deactivate(): void {
if (!this._isActive) {
return
}
this._isActive = false
EventHandler.off(document, EVENT_KEY)
}
_handleFocusin(event: FocusEvent): void {
const { trapElement } = this._config
if (event.target === document || event.target === trapElement || trapElement!.contains(event.target as Node)) {
return
}
const elements = SelectorEngine.focusableChildren(trapElement)
if (elements.length === 0) {
trapElement!.focus()
} else if (this._lastTabNavDirection === TAB_NAV_BACKWARD) {
elements[elements.length - 1].focus()
} else {
elements[0].focus()
}
}
_handleKeydown(event: KeyboardEvent): void {
if (event.key !== TAB_KEY) {
return
}
this._lastTabNavDirection = event.shiftKey ? TAB_NAV_BACKWARD : TAB_NAV_FORWARD
}
}
export default FocusTrap

View File

@@ -0,0 +1,223 @@
/**
* --------------------------------------------------------------------------
* Bootstrap util/index.ts
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* --------------------------------------------------------------------------
*/
const MAX_UID = 1_000_000
const MILLISECONDS_MULTIPLIER = 1000
const TRANSITION_END = 'transitionend'
const parseSelector = (selector: string): string => {
if (selector && window.CSS && window.CSS.escape) {
selector = selector.replace(/#([^\s"#']+)/g, (match, id) => `#${CSS.escape(id)}`)
}
return selector
}
const toType = (object: unknown): string => {
if (object === null || object === undefined) {
return `${object}`
}
return Object.prototype.toString.call(object).match(/\s([a-z]+)/i)![1].toLowerCase()
}
const getUID = (prefix: string): string => {
do {
prefix += Math.floor(Math.random() * MAX_UID)
} while (document.getElementById(prefix))
return prefix
}
const getTransitionDurationFromElement = (element: HTMLElement): number => {
if (!element) {
return 0
}
let { transitionDuration, transitionDelay } = window.getComputedStyle(element)
const floatTransitionDuration = Number.parseFloat(transitionDuration)
const floatTransitionDelay = Number.parseFloat(transitionDelay)
if (!floatTransitionDuration && !floatTransitionDelay) {
return 0
}
transitionDuration = transitionDuration.split(',')[0]
transitionDelay = transitionDelay.split(',')[0]
return (Number.parseFloat(transitionDuration) + Number.parseFloat(transitionDelay)) * MILLISECONDS_MULTIPLIER
}
const triggerTransitionEnd = (element: HTMLElement): void => {
element.dispatchEvent(new Event(TRANSITION_END))
}
const isElement = (object: unknown): object is HTMLElement => {
if (!object || typeof object !== 'object') {
return false
}
return typeof (object as HTMLElement).nodeType !== 'undefined'
}
const getElement = (object: unknown): HTMLElement | null => {
if (isElement(object)) {
return object
}
if (typeof object === 'string' && object.length > 0) {
return document.querySelector(parseSelector(object))
}
return null
}
const isVisible = (element: HTMLElement): boolean => {
if (!isElement(element) || element.getClientRects().length === 0) {
return false
}
const elementIsVisible = getComputedStyle(element).getPropertyValue('visibility') === 'visible'
const closedDetails = element.closest('details:not([open])')
if (!closedDetails) {
return elementIsVisible
}
if (closedDetails !== element) {
const summary = element.closest('summary')
if (summary && summary.parentNode !== closedDetails) {
return false
}
if (summary === null) {
return false
}
}
return elementIsVisible
}
const isDisabled = (element: HTMLElement | null | undefined): boolean => {
if (!element || element.nodeType !== Node.ELEMENT_NODE) {
return true
}
if (element.classList.contains('disabled')) {
return true
}
if ('disabled' in element && typeof element.disabled !== 'undefined') {
return Boolean(element.disabled)
}
return element.hasAttribute('disabled') && element.getAttribute('disabled') !== 'false'
}
const findShadowRoot = (element: Node): ShadowRoot | null => {
if (!document.documentElement.attachShadow) {
return null
}
if (typeof element.getRootNode === 'function') {
const root = element.getRootNode()
return root instanceof ShadowRoot ? root : null
}
if (element instanceof ShadowRoot) {
return element
}
if (!element.parentNode) {
return null
}
return findShadowRoot(element.parentNode)
}
const noop = (): void => {}
/**
* Trick to restart an element's animation
*
* @see https://www.harrytheo.com/blog/2021/02/restart-a-css-animation-with-javascript/#restarting-a-css-animation
*/
const reflow = (element: HTMLElement): void => {
element.offsetHeight // eslint-disable-line no-unused-expressions
}
const isRTL = (): boolean => document.documentElement.dir === 'rtl'
const execute = (possibleCallback: unknown, args: unknown[] = [], defaultValue: unknown = possibleCallback): unknown => {
return typeof possibleCallback === 'function' ? possibleCallback.call(args[0], ...args.slice(1)) : defaultValue
}
const executeAfterTransition = (callback: () => void, transitionElement: HTMLElement, waitForTransition = true): void => {
if (!waitForTransition) {
execute(callback)
return
}
const durationPadding = 5
const emulatedDuration = getTransitionDurationFromElement(transitionElement) + durationPadding
let called = false
const handler = ({ target }: Event): void => {
if (target !== transitionElement) {
return
}
called = true
transitionElement.removeEventListener(TRANSITION_END, handler)
execute(callback)
}
transitionElement.addEventListener(TRANSITION_END, handler)
setTimeout(() => {
if (!called) {
triggerTransitionEnd(transitionElement)
}
}, emulatedDuration)
}
const getNextActiveElement = <T>(list: T[], activeElement: T, shouldGetNext: boolean, isCycleAllowed: boolean): T => {
const listLength = list.length
let index = list.indexOf(activeElement)
if (index === -1) {
return !shouldGetNext && isCycleAllowed ? list[listLength - 1] : list[0]
}
index += shouldGetNext ? 1 : -1
if (isCycleAllowed) {
index = (index + listLength) % listLength
}
return list[Math.max(0, Math.min(index, listLength - 1))]
}
export {
execute,
executeAfterTransition,
findShadowRoot,
getElement,
getNextActiveElement,
getTransitionDurationFromElement,
getUID,
isDisabled,
isElement,
isRTL,
isVisible,
noop,
parseSelector,
reflow,
triggerTransitionEnd,
toType
}

View File

@@ -0,0 +1,110 @@
/**
* --------------------------------------------------------------------------
* Bootstrap util/sanitizer.ts
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* --------------------------------------------------------------------------
*/
import type { AllowList, SanitizeFn } from '../types'
// js-docs-start allow-list
const ARIA_ATTRIBUTE_PATTERN = /^aria-[\w-]*$/i
export const DefaultAllowlist: AllowList = {
'*': ['class', 'dir', 'id', 'lang', 'role', ARIA_ATTRIBUTE_PATTERN],
a: ['target', 'href', 'title', 'rel'],
area: [],
b: [],
br: [],
col: [],
code: [],
dd: [],
div: [],
dl: [],
dt: [],
em: [],
hr: [],
h1: [],
h2: [],
h3: [],
h4: [],
h5: [],
h6: [],
i: [],
img: ['src', 'srcset', 'alt', 'title', 'width', 'height'],
li: [],
ol: [],
p: [],
pre: [],
s: [],
small: [],
span: [],
sub: [],
sup: [],
strong: [],
u: [],
ul: []
}
// js-docs-end allow-list
const uriAttributes = new Set([
'background',
'cite',
'href',
'itemtype',
'longdesc',
'poster',
'src',
'xlink:href'
])
const SAFE_URL_PATTERN = /^(?!javascript:)(?:[a-z0-9+.-]+:|[^&:/?#]*(?:[/?#]|$))/i
const allowedAttribute = (attribute: Attr, allowedAttributeList: (string | RegExp)[]): boolean => {
const attributeName = attribute.nodeName.toLowerCase()
if (allowedAttributeList.includes(attributeName)) {
if (uriAttributes.has(attributeName)) {
return Boolean(SAFE_URL_PATTERN.test(attribute.nodeValue!))
}
return true
}
return allowedAttributeList.filter(attributeRegex => attributeRegex instanceof RegExp)
.some(regex => (regex as RegExp).test(attributeName))
}
export function sanitizeHtml(unsafeHtml: string, allowList: AllowList, sanitizeFunction?: SanitizeFn): string {
if (!unsafeHtml.length) {
return unsafeHtml
}
if (sanitizeFunction && typeof sanitizeFunction === 'function') {
return sanitizeFunction(unsafeHtml)
}
const domParser = new window.DOMParser()
const createdDocument = domParser.parseFromString(unsafeHtml, 'text/html')
const elements = Array.from(createdDocument.body.querySelectorAll('*'))
for (const element of elements) {
const elementName = element.nodeName.toLowerCase()
if (!Object.keys(allowList).includes(elementName)) {
element.remove()
continue
}
const attributeList = Array.from(element.attributes)
const allowedAttributes = [...(allowList['*'] || []), ...(allowList[elementName] || [])]
for (const attribute of attributeList) {
if (!allowedAttribute(attribute, allowedAttributes)) {
element.removeAttribute(attribute.nodeName)
}
}
}
return createdDocument.body.innerHTML
}

View File

@@ -0,0 +1,102 @@
/**
* --------------------------------------------------------------------------
* Bootstrap util/scrollbar.ts
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* --------------------------------------------------------------------------
*/
import Manipulator from '../dom/manipulator.js'
import SelectorEngine from '../dom/selector-engine.js'
import { isElement } from './index'
const SELECTOR_FIXED_CONTENT = '.fixed-top, .fixed-bottom, .is-fixed, .sticky-top'
const SELECTOR_STICKY_CONTENT = '.sticky-top'
const PROPERTY_PADDING = 'padding-right'
const PROPERTY_MARGIN = 'margin-right'
class ScrollBarHelper {
_element: HTMLElement
constructor() {
this._element = document.body
}
getWidth(): number {
const documentWidth = document.documentElement.clientWidth
return Math.abs(window.innerWidth - documentWidth)
}
hide(): void {
const width = this.getWidth()
this._disableOverFlow()
this._setElementAttributes(this._element, PROPERTY_PADDING, calculatedValue => calculatedValue + width)
this._setElementAttributes(SELECTOR_FIXED_CONTENT, PROPERTY_PADDING, calculatedValue => calculatedValue + width)
this._setElementAttributes(SELECTOR_STICKY_CONTENT, PROPERTY_MARGIN, calculatedValue => calculatedValue - width)
}
reset(): void {
this._resetElementAttributes(this._element, 'overflow')
this._resetElementAttributes(this._element, PROPERTY_PADDING)
this._resetElementAttributes(SELECTOR_FIXED_CONTENT, PROPERTY_PADDING)
this._resetElementAttributes(SELECTOR_STICKY_CONTENT, PROPERTY_MARGIN)
}
isOverflowing(): boolean {
return this.getWidth() > 0
}
_disableOverFlow(): void {
this._saveInitialAttribute(this._element, 'overflow')
this._element.style.overflow = 'hidden'
}
_setElementAttributes(selector: string | HTMLElement, styleProperty: string, callback: (value: number) => number): void {
const scrollbarWidth = this.getWidth()
const manipulationCallBack = (element: HTMLElement): void => {
if (element !== this._element && window.innerWidth > element.clientWidth + scrollbarWidth) {
return
}
this._saveInitialAttribute(element, styleProperty)
const calculatedValue = window.getComputedStyle(element).getPropertyValue(styleProperty)
element.style.setProperty(styleProperty, `${callback(Number.parseFloat(calculatedValue))}px`)
}
this._applyManipulationCallback(selector, manipulationCallBack)
}
_saveInitialAttribute(element: HTMLElement, styleProperty: string): void {
const actualValue = element.style.getPropertyValue(styleProperty)
if (actualValue) {
Manipulator.setDataAttribute(element, styleProperty, actualValue)
}
}
_resetElementAttributes(selector: string | HTMLElement, styleProperty: string): void {
const manipulationCallBack = (element: HTMLElement): void => {
const value = Manipulator.getDataAttribute(element, styleProperty)
if (value === null) {
element.style.removeProperty(styleProperty)
return
}
Manipulator.removeDataAttribute(element, styleProperty)
element.style.setProperty(styleProperty, String(value))
}
this._applyManipulationCallback(selector, manipulationCallBack)
}
_applyManipulationCallback(selector: string | HTMLElement, callBack: (element: HTMLElement) => void): void {
if (isElement(selector)) {
callBack(selector)
return
}
for (const sel of SelectorEngine.find(selector, this._element)) {
callBack(sel)
}
}
}
export default ScrollBarHelper

View File

@@ -0,0 +1,145 @@
/**
* --------------------------------------------------------------------------
* Bootstrap util/swipe.ts
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* --------------------------------------------------------------------------
*/
import EventHandler from '../dom/event-handler.js'
import Config from './config'
import { execute } from './index'
import type { ComponentConfig, ComponentConfigType } from '../types'
const NAME = 'swipe'
const EVENT_KEY = '.bs.swipe'
const EVENT_TOUCHSTART = `touchstart${EVENT_KEY}`
const EVENT_TOUCHMOVE = `touchmove${EVENT_KEY}`
const EVENT_TOUCHEND = `touchend${EVENT_KEY}`
const EVENT_POINTERDOWN = `pointerdown${EVENT_KEY}`
const EVENT_POINTERUP = `pointerup${EVENT_KEY}`
const POINTER_TYPE_TOUCH = 'touch'
const POINTER_TYPE_PEN = 'pen'
const CLASS_NAME_POINTER_EVENT = 'pointer-event'
const SWIPE_THRESHOLD = 40
interface SwipeConfig {
endCallback: (() => void) | null
leftCallback: (() => void) | null
rightCallback: (() => void) | null
}
const Default: SwipeConfig = {
endCallback: null,
leftCallback: null,
rightCallback: null
}
const DefaultType: ComponentConfigType = {
endCallback: '(function|null)',
leftCallback: '(function|null)',
rightCallback: '(function|null)'
}
class Swipe extends Config {
declare _config: SwipeConfig & ComponentConfig
_element: HTMLElement
_deltaX: number
_supportPointerEvents: boolean
constructor(element: HTMLElement, config?: ComponentConfig) {
super()
this._element = element
if (!element || !Swipe.isSupported()) {
return
}
this._config = this._getConfig(config) as SwipeConfig & ComponentConfig
this._deltaX = 0
this._supportPointerEvents = Boolean(window.PointerEvent)
this._initEvents()
}
static get Default(): ComponentConfig {
return Default
}
static get DefaultType(): ComponentConfigType {
return DefaultType
}
static get NAME(): string {
return NAME
}
dispose(): void {
EventHandler.off(this._element, EVENT_KEY)
}
_start(event: Event): void {
if (!this._supportPointerEvents) {
this._deltaX = (event as TouchEvent).touches[0].clientX
return
}
if (this._eventIsPointerPenTouch(event as PointerEvent)) {
this._deltaX = (event as PointerEvent).clientX
}
}
_end(event: Event): void {
if (this._eventIsPointerPenTouch(event as PointerEvent)) {
this._deltaX = (event as PointerEvent).clientX - this._deltaX
}
this._handleSwipe()
execute(this._config.endCallback)
}
_move(event: Event): void {
this._deltaX = (event as TouchEvent).touches && (event as TouchEvent).touches.length > 1 ?
0 :
(event as TouchEvent).touches[0].clientX - this._deltaX
}
_handleSwipe(): void {
const absDeltaX = Math.abs(this._deltaX)
if (absDeltaX <= SWIPE_THRESHOLD) {
return
}
const direction = absDeltaX / this._deltaX
this._deltaX = 0
if (!direction) {
return
}
execute(direction > 0 ? this._config.rightCallback : this._config.leftCallback)
}
_initEvents(): void {
if (this._supportPointerEvents) {
EventHandler.on(this._element, EVENT_POINTERDOWN, (event: Event) => this._start(event))
EventHandler.on(this._element, EVENT_POINTERUP, (event: Event) => this._end(event))
this._element.classList.add(CLASS_NAME_POINTER_EVENT)
} else {
EventHandler.on(this._element, EVENT_TOUCHSTART, (event: Event) => this._start(event))
EventHandler.on(this._element, EVENT_TOUCHMOVE, (event: Event) => this._move(event))
EventHandler.on(this._element, EVENT_TOUCHEND, (event: Event) => this._end(event))
}
}
_eventIsPointerPenTouch(event: PointerEvent): boolean {
return this._supportPointerEvents && (event.pointerType === POINTER_TYPE_PEN || event.pointerType === POINTER_TYPE_TOUCH)
}
static isSupported(): boolean {
return 'ontouchstart' in document.documentElement || navigator.maxTouchPoints > 0
}
}
export default Swipe

View File

@@ -0,0 +1,162 @@
/**
* --------------------------------------------------------------------------
* Bootstrap util/template-factory.ts
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* --------------------------------------------------------------------------
*/
import SelectorEngine from '../dom/selector-engine.js'
import Config from './config'
import { DefaultAllowlist, sanitizeHtml } from './sanitizer'
import { execute, getElement, isElement } from './index'
import type { AllowList, ComponentConfig, ComponentConfigType, SanitizeFn } from '../types'
const NAME = 'TemplateFactory'
interface TemplateFactoryConfig {
allowList: AllowList
content: Record<string, unknown>
extraClass: string | (() => string)
html: boolean
sanitize: boolean
sanitizeFn: SanitizeFn | null
template: string
}
const Default: TemplateFactoryConfig = {
allowList: DefaultAllowlist,
content: {},
extraClass: '',
html: false,
sanitize: true,
sanitizeFn: null,
template: '<div></div>'
}
const DefaultType: ComponentConfigType = {
allowList: 'object',
content: 'object',
extraClass: '(string|function)',
html: 'boolean',
sanitize: 'boolean',
sanitizeFn: '(null|function)',
template: 'string'
}
const DefaultContentType: ComponentConfigType = {
entry: '(string|element|function|null)',
selector: '(string|element)'
}
class TemplateFactory extends Config {
declare _config: TemplateFactoryConfig & ComponentConfig
constructor(config?: ComponentConfig) {
super()
this._config = this._getConfig(config) as TemplateFactoryConfig & ComponentConfig
}
static get Default(): ComponentConfig {
return Default
}
static get DefaultType(): ComponentConfigType {
return DefaultType
}
static get NAME(): string {
return NAME
}
getContent(): unknown[] {
return Object.values(this._config.content)
.map(config => this._resolvePossibleFunction(config))
.filter(Boolean)
}
hasContent(): boolean {
return this.getContent().length > 0
}
changeContent(content: Record<string, unknown>): this {
this._checkContent(content)
this._config.content = { ...this._config.content, ...content }
return this
}
toHtml(): Element {
const templateWrapper = document.createElement('div')
templateWrapper.innerHTML = this._maybeSanitize(this._config.template)
for (const [selector, text] of Object.entries(this._config.content)) {
this._setContent(templateWrapper, text, selector)
}
const template = templateWrapper.children[0]
const extraClass = this._resolvePossibleFunction(this._config.extraClass)
if (extraClass) {
template.classList.add(...(extraClass as string).split(' '))
}
return template
}
_typeCheckConfig(config: ComponentConfig): void {
super._typeCheckConfig(config)
this._checkContent(config.content)
}
_checkContent(arg: Record<string, unknown>): void {
for (const [selector, content] of Object.entries(arg)) {
super._typeCheckConfig({ selector, entry: content }, DefaultContentType)
}
}
_setContent(template: HTMLElement, content: unknown, selector: string): void {
const templateElement = SelectorEngine.findOne(selector, template)
if (!templateElement) {
return
}
content = this._resolvePossibleFunction(content)
if (!content) {
templateElement.remove()
return
}
if (isElement(content)) {
this._putElementInTemplate(getElement(content)!, templateElement)
return
}
if (this._config.html) {
templateElement.innerHTML = this._maybeSanitize(content as string)
return
}
templateElement.textContent = content as string
}
_maybeSanitize(arg: string): string {
return this._config.sanitize ? sanitizeHtml(arg, this._config.allowList, this._config.sanitizeFn!) : arg
}
_resolvePossibleFunction(arg: unknown): unknown {
return execute(arg, [undefined, this])
}
_putElementInTemplate(element: HTMLElement, templateElement: Element): void {
if (this._config.html) {
templateElement.innerHTML = ''
templateElement.append(element)
return
}
templateElement.textContent = element.textContent
}
}
export default TemplateFactory

View File

@@ -0,0 +1,33 @@
const FIXTURE_ID = 'fixture'
export const getFixture = (): HTMLElement => {
let el = document.getElementById(FIXTURE_ID)
if (!el) {
el = document.createElement('div')
el.id = FIXTURE_ID
el.style.position = 'absolute'
el.style.top = '-10000px'
el.style.left = '-10000px'
el.style.width = '10000px'
el.style.height = '10000px'
document.body.append(el)
}
return el
}
export const clearFixture = (): void => {
getFixture().innerHTML = ''
}
export const createEvent = (eventName: string, parameters: EventInit = {}): Event => {
return new Event(eventName, parameters)
}
export const clearBodyAndDocument = (): void => {
for (const attribute of ['data-bs-padding-right', 'data-tblr-padding-right', 'style']) {
document.documentElement.removeAttribute(attribute)
document.body.removeAttribute(attribute)
}
}

View File

@@ -0,0 +1,192 @@
import { describe, it, expect, beforeAll, afterEach } from 'vitest'
import Alert from '../../src/bootstrap/alert'
import { clearFixture, getFixture } from '../helpers/fixture'
describe('Alert', () => {
let fixtureEl: HTMLElement
beforeAll(() => {
fixtureEl = getFixture()
})
afterEach(() => {
clearFixture()
})
it('should accept element as CSS selector or DOM element', () => {
fixtureEl.innerHTML = '<div class="alert"></div>'
const alertEl = fixtureEl.querySelector('.alert')!
const alertBySelector = new Alert('.alert')
const alertByElement = new Alert(alertEl)
expect(alertBySelector._element).toBe(alertEl)
expect(alertByElement._element).toBe(alertEl)
})
it('should return version', () => {
expect(typeof Alert.VERSION).toBe('string')
})
describe('DATA_KEY', () => {
it('should return plugin data key', () => {
expect(Alert.DATA_KEY).toBe('bs.alert')
})
})
describe('data-api', () => {
it('should close an alert without instantiating manually', () => {
fixtureEl.innerHTML = [
'<div class="alert">',
' <button type="button" data-bs-dismiss="alert">x</button>',
'</div>'
].join('')
const button = document.querySelector('button')!
button.click()
expect(document.querySelectorAll('.alert')).toHaveLength(0)
})
it('should close an alert with parent selector', () => {
fixtureEl.innerHTML = [
'<div class="alert">',
' <button type="button" data-bs-target=".alert" data-bs-dismiss="alert">x</button>',
'</div>'
].join('')
const button = document.querySelector('button')!
button.click()
expect(document.querySelectorAll('.alert')).toHaveLength(0)
})
it('should close an alert via data-tblr-dismiss', () => {
fixtureEl.innerHTML = [
'<div class="alert">',
' <button type="button" data-tblr-dismiss="alert">x</button>',
'</div>'
].join('')
const button = document.querySelector('button')!
button.click()
expect(document.querySelectorAll('.alert')).toHaveLength(0)
})
it('should close an alert via data-tblr-dismiss with data-tblr-target', () => {
fixtureEl.innerHTML = [
'<div class="alert">',
' <button type="button" data-tblr-target=".alert" data-tblr-dismiss="alert">x</button>',
'</div>'
].join('')
const button = document.querySelector('button')!
button.click()
expect(document.querySelectorAll('.alert')).toHaveLength(0)
})
})
describe('close', () => {
it('should close an alert', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = '<div class="alert"></div>'
const alertEl = document.querySelector('.alert')!
const alert = new Alert(alertEl)
alertEl.addEventListener('closed.bs.alert', () => {
expect(document.querySelectorAll('.alert')).toHaveLength(0)
resolve()
})
alert.close()
})
})
it('should close alert with fade class', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = '<div class="alert fade"></div>'
const alertEl = document.querySelector('.alert')!
const alert = new Alert(alertEl)
alertEl.addEventListener('closed.bs.alert', () => {
expect(document.querySelectorAll('.alert')).toHaveLength(0)
resolve()
})
alert.close()
})
})
it('should not remove alert if close event is prevented', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = '<div class="alert"></div>'
const alertEl = document.querySelector('.alert')!
const alert = new Alert(alertEl)
alertEl.addEventListener('close.bs.alert', event => {
event.preventDefault()
setTimeout(() => {
expect(document.querySelector('.alert')).not.toBeNull()
resolve()
}, 10)
})
alert.close()
})
})
})
describe('dispose', () => {
it('should dispose an alert', () => {
fixtureEl.innerHTML = '<div class="alert"></div>'
const alertEl = document.querySelector('.alert')!
const alert = new Alert(alertEl)
expect(Alert.getInstance(alertEl)).not.toBeNull()
alert.dispose()
expect(Alert.getInstance(alertEl)).toBeNull()
})
})
describe('getInstance', () => {
it('should return alert instance', () => {
fixtureEl.innerHTML = '<div></div>'
const div = fixtureEl.querySelector('div')!
const alert = new Alert(div)
expect(Alert.getInstance(div)).toBe(alert)
expect(Alert.getInstance(div)).toBeInstanceOf(Alert)
})
it('should return null when there is no alert instance', () => {
fixtureEl.innerHTML = '<div></div>'
const div = fixtureEl.querySelector('div')!
expect(Alert.getInstance(div)).toBeNull()
})
})
describe('getOrCreateInstance', () => {
it('should return alert instance', () => {
fixtureEl.innerHTML = '<div></div>'
const div = fixtureEl.querySelector('div')!
const alert = new Alert(div)
expect(Alert.getOrCreateInstance(div)).toBe(alert)
expect(Alert.getOrCreateInstance(div)).toBeInstanceOf(Alert)
})
it('should return new instance when there is no alert instance', () => {
fixtureEl.innerHTML = '<div></div>'
const div = fixtureEl.querySelector('div')!
expect(Alert.getInstance(div)).toBeNull()
expect(Alert.getOrCreateInstance(div)).toBeInstanceOf(Alert)
})
})
})

View File

@@ -0,0 +1,166 @@
import { describe, it, expect, beforeAll, afterEach, vi } from 'vitest'
import BaseComponent from '../../src/bootstrap/base-component'
import EventHandler from '../../src/bootstrap/dom/event-handler'
import { noop } from '../../src/bootstrap/util/index'
import { clearFixture, getFixture } from '../helpers/fixture'
class DummyClass extends BaseComponent {
constructor(element: string | HTMLElement) {
super(element)
EventHandler.on(this._element, `click${DummyClass.EVENT_KEY}`, noop as EventListener)
}
static get NAME(): string {
return 'dummy'
}
}
describe('BaseComponent', () => {
let fixtureEl: HTMLElement
let element: HTMLElement
let instance: DummyClass
const createInstance = () => {
fixtureEl.innerHTML = '<div id="foo"></div>'
element = fixtureEl.querySelector('#foo')!
instance = new DummyClass(element)
}
beforeAll(() => {
fixtureEl = getFixture()
})
afterEach(() => {
clearFixture()
})
describe('Static Methods', () => {
it('VERSION should return a string', () => {
expect(typeof DummyClass.VERSION).toBe('string')
})
it('DATA_KEY should return plugin data key', () => {
expect(DummyClass.DATA_KEY).toBe('bs.dummy')
})
it('NAME should throw if not overridden', () => {
expect(() => BaseComponent.NAME).toThrow(Error)
})
it('NAME should return plugin name', () => {
expect(DummyClass.NAME).toBe('dummy')
})
it('EVENT_KEY should return plugin event key', () => {
expect(DummyClass.EVENT_KEY).toBe('.bs.dummy')
})
it('eventName should return namespaced event', () => {
expect(DummyClass.eventName('show')).toBe('show.bs.dummy')
})
})
describe('constructor', () => {
it('should accept element passed as DOM element', () => {
fixtureEl.innerHTML = '<div id="foo"></div>'
const el = fixtureEl.querySelector('#foo')!
const inst = new DummyClass(el)
expect(inst._element).toBe(el)
})
it('should accept element passed as CSS selector', () => {
fixtureEl.innerHTML = '<div id="bar"></div>'
const inst = new DummyClass('#bar')
expect(inst._element).toBe(fixtureEl.querySelector('#bar'))
})
it('should not initialize if element is not found', () => {
fixtureEl.innerHTML = ''
const inst = new DummyClass('#nonexistent')
expect(inst._element).toBeUndefined()
})
})
describe('dispose', () => {
it('should dispose a component', () => {
createInstance()
expect(DummyClass.getInstance(element)).not.toBeNull()
instance.dispose()
expect(DummyClass.getInstance(element)).toBeNull()
expect(instance._element).toBeNull()
})
it('should de-register element event listeners', () => {
createInstance()
const spy = vi.spyOn(EventHandler, 'off')
instance.dispose()
expect(spy).toHaveBeenCalledWith(element, DummyClass.EVENT_KEY)
vi.restoreAllMocks()
})
})
describe('getInstance', () => {
it('should return an instance', () => {
createInstance()
expect(DummyClass.getInstance(element)).toBe(instance)
expect(DummyClass.getInstance(element)).toBeInstanceOf(DummyClass)
})
it('should accept CSS selector', () => {
createInstance()
expect(DummyClass.getInstance('#foo')).toBe(instance)
})
it('should return null when there is no instance', () => {
fixtureEl.innerHTML = '<div></div>'
const div = fixtureEl.querySelector('div')!
expect(DummyClass.getInstance(div)).toBeNull()
})
})
describe('getOrCreateInstance', () => {
it('should return existing instance', () => {
createInstance()
expect(DummyClass.getOrCreateInstance(element)).toBe(instance)
expect(DummyClass.getOrCreateInstance(element)).toBeInstanceOf(DummyClass)
})
it('should create new instance if none exists', () => {
fixtureEl.innerHTML = '<div id="foo"></div>'
element = fixtureEl.querySelector('#foo')!
expect(DummyClass.getInstance(element)).toBeNull()
expect(DummyClass.getOrCreateInstance(element)).toBeInstanceOf(DummyClass)
})
it('should pass null config when config is not an object', () => {
fixtureEl.innerHTML = '<div id="foo"></div>'
element = fixtureEl.querySelector('#foo')!
const inst = DummyClass.getOrCreateInstance(element, 'string-config' as unknown as Record<string, unknown>)
expect(inst).toBeInstanceOf(DummyClass)
})
})
describe('_queueCallback', () => {
it('should execute callback immediately when isAnimated is false', () => {
createInstance()
const callback = vi.fn()
instance._queueCallback(callback, element, false)
expect(callback).toHaveBeenCalledOnce()
})
it('should execute callback after transition when isAnimated is true', () => {
createInstance()
const callback = vi.fn()
instance._queueCallback(callback, element, true)
expect(callback).not.toHaveBeenCalled()
element.dispatchEvent(new Event('transitionend'))
expect(callback).toHaveBeenCalledOnce()
})
})
})

View File

@@ -0,0 +1,170 @@
import { describe, it, expect, beforeAll, afterEach, vi } from 'vitest'
import Button from '../../src/bootstrap/button'
import { clearFixture, getFixture } from '../helpers/fixture'
describe('Button', () => {
let fixtureEl: HTMLElement
beforeAll(() => {
fixtureEl = getFixture()
})
afterEach(() => {
clearFixture()
})
it('should accept element as CSS selector or DOM element', () => {
fixtureEl.innerHTML = '<button data-bs-toggle="button">Placeholder</button>'
const buttonEl = fixtureEl.querySelector('[data-bs-toggle="button"]')!
const buttonBySelector = new Button('[data-bs-toggle="button"]')
const buttonByElement = new Button(buttonEl)
expect(buttonBySelector._element).toBe(buttonEl)
expect(buttonByElement._element).toBe(buttonEl)
})
describe('VERSION', () => {
it('should return plugin version', () => {
expect(typeof Button.VERSION).toBe('string')
})
})
describe('DATA_KEY', () => {
it('should return plugin data key', () => {
expect(Button.DATA_KEY).toBe('bs.button')
})
})
describe('data-api', () => {
it('should toggle active class on click', () => {
fixtureEl.innerHTML = [
'<button class="btn" data-bs-toggle="button">btn</button>',
'<button class="btn testParent" data-bs-toggle="button"><div class="test"></div></button>'
].join('')
const btn = fixtureEl.querySelector('.btn') as HTMLElement
const divTest = fixtureEl.querySelector('.test') as HTMLElement
const btnTestParent = fixtureEl.querySelector('.testParent') as HTMLElement
expect(btn.classList.contains('active')).toBe(false)
btn.click()
expect(btn.classList.contains('active')).toBe(true)
btn.click()
expect(btn.classList.contains('active')).toBe(false)
divTest.click()
expect(btnTestParent.classList.contains('active')).toBe(true)
})
})
describe('toggle', () => {
it('should toggle aria-pressed', () => {
fixtureEl.innerHTML = '<button class="btn" data-bs-toggle="button" aria-pressed="false"></button>'
const btnEl = fixtureEl.querySelector('.btn')!
const button = new Button(btnEl)
expect(btnEl.getAttribute('aria-pressed')).toBe('false')
expect(btnEl.classList.contains('active')).toBe(false)
button.toggle()
expect(btnEl.getAttribute('aria-pressed')).toBe('true')
expect(btnEl.classList.contains('active')).toBe(true)
})
})
describe('dispose', () => {
it('should dispose a button', () => {
fixtureEl.innerHTML = '<button class="btn" data-bs-toggle="button"></button>'
const btnEl = fixtureEl.querySelector('.btn')!
const button = new Button(btnEl)
expect(Button.getInstance(btnEl)).not.toBeNull()
button.dispose()
expect(Button.getInstance(btnEl)).toBeNull()
})
})
describe('data-tblr-toggle', () => {
it('should toggle active class via data-tblr-toggle', () => {
fixtureEl.innerHTML = '<button class="btn" data-tblr-toggle="button">btn</button>'
const btn = fixtureEl.querySelector('.btn') as HTMLElement
expect(btn.classList.contains('active')).toBe(false)
btn.click()
expect(btn.classList.contains('active')).toBe(true)
btn.click()
expect(btn.classList.contains('active')).toBe(false)
})
it('should toggle active on child click with data-tblr-toggle', () => {
fixtureEl.innerHTML = '<button class="btn" data-tblr-toggle="button"><span class="inner">text</span></button>'
const inner = fixtureEl.querySelector('.inner') as HTMLElement
const btn = fixtureEl.querySelector('.btn') as HTMLElement
inner.click()
expect(btn.classList.contains('active')).toBe(true)
})
})
describe('data-api edge cases', () => {
it('should do nothing when closest returns null', () => {
fixtureEl.innerHTML = '<div id="wrapper"><span data-bs-toggle="button"></span></div>'
const span = fixtureEl.querySelector('span')!
vi.spyOn(span, 'closest').mockReturnValue(null)
span.click()
expect(Button.getInstance(span)).toBeNull()
})
})
describe('getInstance', () => {
it('should return button instance', () => {
fixtureEl.innerHTML = '<div></div>'
const div = fixtureEl.querySelector('div')!
const button = new Button(div)
expect(Button.getInstance(div)).toBe(button)
expect(Button.getInstance(div)).toBeInstanceOf(Button)
})
it('should return null when there is no button instance', () => {
fixtureEl.innerHTML = '<div></div>'
const div = fixtureEl.querySelector('div')!
expect(Button.getInstance(div)).toBeNull()
})
})
describe('getOrCreateInstance', () => {
it('should return button instance', () => {
fixtureEl.innerHTML = '<div></div>'
const div = fixtureEl.querySelector('div')!
const button = new Button(div)
expect(Button.getOrCreateInstance(div)).toBe(button)
expect(Button.getOrCreateInstance(div)).toBeInstanceOf(Button)
})
it('should return new instance when there is no button instance', () => {
fixtureEl.innerHTML = '<div></div>'
const div = fixtureEl.querySelector('div')!
expect(Button.getInstance(div)).toBeNull()
expect(Button.getOrCreateInstance(div)).toBeInstanceOf(Button)
})
})
})

View File

@@ -0,0 +1,639 @@
import { describe, it, expect, beforeAll, afterEach, vi } from 'vitest'
import Carousel from '../../src/bootstrap/carousel'
import EventHandler from '../../src/bootstrap/dom/event-handler'
import { clearFixture, createEvent, getFixture } from '../helpers/fixture'
describe('Carousel', () => {
let fixtureEl: HTMLElement
beforeAll(() => {
fixtureEl = getFixture()
})
afterEach(() => {
clearFixture()
vi.restoreAllMocks()
})
describe('VERSION', () => {
it('should return plugin version', () => {
expect(typeof Carousel.VERSION).toBe('string')
})
})
describe('Default', () => {
it('should return plugin default config', () => {
expect(typeof Carousel.Default).toBe('object')
})
})
describe('DATA_KEY', () => {
it('should return plugin data key', () => {
expect(Carousel.DATA_KEY).toBe('bs.carousel')
})
})
describe('constructor', () => {
it('should accept element as CSS selector or DOM element', () => {
fixtureEl.innerHTML = '<div id="myCarousel" class="carousel slide"></div>'
const carouselEl = fixtureEl.querySelector('#myCarousel')!
const carouselBySelector = new Carousel('#myCarousel')
const carouselByElement = new Carousel(carouselEl)
expect(carouselBySelector._element).toBe(carouselEl)
expect(carouselByElement._element).toBe(carouselEl)
})
it('should start cycling if ride=carousel', () => {
fixtureEl.innerHTML = '<div id="myCarousel" class="carousel slide" data-bs-ride="carousel"></div>'
const carousel = new Carousel('#myCarousel')
expect(carousel._interval).not.toBeNull()
carousel.dispose()
})
it('should not start cycling if ride!=carousel', () => {
fixtureEl.innerHTML = '<div id="myCarousel" class="carousel slide" data-bs-ride="true"></div>'
const carousel = new Carousel('#myCarousel')
expect(carousel._interval).toBeNull()
})
it('should go to next item on right arrow key', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<div id="myCarousel" class="carousel slide">',
' <div class="carousel-inner">',
' <div class="carousel-item active">item 1</div>',
' <div id="item2" class="carousel-item">item 2</div>',
' <div class="carousel-item">item 3</div>',
' </div>',
'</div>'
].join('')
const carouselEl = fixtureEl.querySelector('#myCarousel')!
const carousel = new Carousel(carouselEl, { keyboard: true })
carouselEl.addEventListener('slid.bs.carousel', () => {
expect(fixtureEl.querySelector('.active')).toBe(fixtureEl.querySelector('#item2'))
carousel.dispose()
resolve()
})
const keydown = new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })
carouselEl.dispatchEvent(keydown)
})
})
it('should go to previous item on left arrow key', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<div id="myCarousel" class="carousel slide">',
' <div class="carousel-inner">',
' <div id="item1" class="carousel-item">item 1</div>',
' <div class="carousel-item active">item 2</div>',
' <div class="carousel-item">item 3</div>',
' </div>',
'</div>'
].join('')
const carouselEl = fixtureEl.querySelector('#myCarousel')!
const carousel = new Carousel(carouselEl, { keyboard: true })
carouselEl.addEventListener('slid.bs.carousel', () => {
expect(fixtureEl.querySelector('.active')).toBe(fixtureEl.querySelector('#item1'))
carousel.dispose()
resolve()
})
const keydown = new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true })
carouselEl.dispatchEvent(keydown)
})
})
it('should not prevent keydown for non-arrow keys', () => {
fixtureEl.innerHTML = [
'<div id="myCarousel" class="carousel slide">',
' <div class="carousel-inner">',
' <div class="carousel-item active">item 1</div>',
' <div class="carousel-item">item 2</div>',
' </div>',
'</div>'
].join('')
const carouselEl = fixtureEl.querySelector('#myCarousel')!
new Carousel(carouselEl, { keyboard: true })
const spy = vi.spyOn(Event.prototype, 'preventDefault')
const keydown = new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })
carouselEl.dispatchEvent(keydown)
expect(spy).not.toHaveBeenCalled()
})
it('should ignore keyboard in input/textarea', () => {
fixtureEl.innerHTML = [
'<div id="myCarousel" class="carousel slide">',
' <div class="carousel-inner">',
' <div class="carousel-item active"><input type="text"></div>',
' <div class="carousel-item">item 2</div>',
' </div>',
'</div>'
].join('')
const carouselEl = fixtureEl.querySelector('#myCarousel')!
const carousel = new Carousel(carouselEl, { keyboard: true })
const slideSpy = vi.spyOn(carousel, '_slide')
const input = fixtureEl.querySelector('input')!
const keydown = new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })
Object.defineProperty(keydown, 'target', { value: input, configurable: true })
carouselEl.dispatchEvent(keydown)
expect(slideSpy).not.toHaveBeenCalled()
})
it('should not slide if already sliding', () => {
fixtureEl.innerHTML = '<div id="myCarousel" class="carousel slide"></div>'
const carouselEl = fixtureEl.querySelector('#myCarousel')!
const carousel = new Carousel(carouselEl)
const triggerSpy = vi.spyOn(EventHandler, 'trigger')
carousel._isSliding = true
const keydown = new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })
carouselEl.dispatchEvent(keydown)
expect(triggerSpy).not.toHaveBeenCalled()
})
})
describe('next', () => {
it('should slide to next item', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<div id="myCarousel" class="carousel slide">',
' <div class="carousel-inner">',
' <div class="carousel-item active">item 1</div>',
' <div id="item2" class="carousel-item">item 2</div>',
' </div>',
'</div>'
].join('')
const carouselEl = fixtureEl.querySelector('#myCarousel')!
const carousel = new Carousel(carouselEl)
carouselEl.addEventListener('slid.bs.carousel', () => {
expect(fixtureEl.querySelector('#item2')!.classList.contains('active')).toBe(true)
carousel.dispose()
resolve()
})
carousel.next()
})
})
})
describe('prev', () => {
it('should stay at start when wrap is false', () => {
return new Promise<void>((resolve, reject) => {
fixtureEl.innerHTML = [
'<div id="myCarousel" class="carousel slide">',
' <div class="carousel-inner">',
' <div id="one" class="carousel-item active"></div>',
' <div id="two" class="carousel-item"></div>',
' </div>',
'</div>'
].join('')
const carouselEl = fixtureEl.querySelector('#myCarousel')!
const carousel = new Carousel(carouselEl, { wrap: false })
carouselEl.addEventListener('slid.bs.carousel', () => {
reject(new Error('should not slide'))
})
carousel.prev()
setTimeout(() => {
expect(fixtureEl.querySelector('#one')!.classList.contains('active')).toBe(true)
resolve()
}, 50)
})
})
})
describe('pause', () => {
it('should clear interval', () => {
fixtureEl.innerHTML = '<div id="myCarousel" class="carousel slide"></div>'
const carouselEl = fixtureEl.querySelector('#myCarousel')!
const carousel = new Carousel(carouselEl)
carousel.cycle()
expect(carousel._interval).not.toBeNull()
carousel.pause()
expect(carousel._interval).toBeNull()
})
})
describe('cycle', () => {
it('should create an interval', () => {
fixtureEl.innerHTML = '<div id="myCarousel" class="carousel slide"></div>'
const carouselEl = fixtureEl.querySelector('#myCarousel')!
const carousel = new Carousel(carouselEl)
carousel.cycle()
expect(carousel._interval).not.toBeNull()
carousel.dispose()
})
})
describe('to', () => {
it('should go to specific index', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<div id="myCarousel" class="carousel slide">',
' <div class="carousel-inner">',
' <div class="carousel-item active">item 1</div>',
' <div id="item2" class="carousel-item">item 2</div>',
' <div id="item3" class="carousel-item">item 3</div>',
' </div>',
'</div>'
].join('')
const carouselEl = fixtureEl.querySelector('#myCarousel')!
const carousel = new Carousel(carouselEl)
carouselEl.addEventListener('slid.bs.carousel', () => {
expect(fixtureEl.querySelector('#item3')!.classList.contains('active')).toBe(true)
carousel.dispose()
resolve()
})
carousel.to(2)
})
})
it('should ignore invalid index', () => {
fixtureEl.innerHTML = [
'<div id="myCarousel" class="carousel slide">',
' <div class="carousel-inner">',
' <div class="carousel-item active">item 1</div>',
' <div class="carousel-item">item 2</div>',
' </div>',
'</div>'
].join('')
const carouselEl = fixtureEl.querySelector('#myCarousel')!
const carousel = new Carousel(carouselEl)
const spy = vi.spyOn(EventHandler, 'trigger')
carousel.to(-1)
carousel.to(10)
expect(spy).not.toHaveBeenCalled()
})
})
describe('dispose', () => {
it('should dispose carousel', () => {
fixtureEl.innerHTML = '<div id="myCarousel" class="carousel slide"></div>'
const carouselEl = fixtureEl.querySelector('#myCarousel')!
const carousel = new Carousel(carouselEl)
expect(Carousel.getInstance(carouselEl)).not.toBeNull()
carousel.dispose()
expect(Carousel.getInstance(carouselEl)).toBeNull()
})
})
describe('getInstance', () => {
it('should return null if no instance', () => {
expect(Carousel.getInstance(fixtureEl)).toBeNull()
})
it('should return carousel instance', () => {
fixtureEl.innerHTML = '<div id="myCarousel" class="carousel slide"></div>'
const carouselEl = fixtureEl.querySelector('#myCarousel')!
const carousel = new Carousel(carouselEl)
expect(Carousel.getInstance(carouselEl)).toBe(carousel)
})
})
describe('getOrCreateInstance', () => {
it('should return carousel instance', () => {
fixtureEl.innerHTML = '<div id="myCarousel" class="carousel slide"></div>'
const carouselEl = fixtureEl.querySelector('#myCarousel')!
const carousel = new Carousel(carouselEl)
expect(Carousel.getOrCreateInstance(carouselEl)).toBe(carousel)
})
it('should return new instance', () => {
fixtureEl.innerHTML = '<div id="myCarousel" class="carousel slide"></div>'
const carouselEl = fixtureEl.querySelector('#myCarousel')!
expect(Carousel.getInstance(carouselEl)).toBeNull()
expect(Carousel.getOrCreateInstance(carouselEl)).toBeInstanceOf(Carousel)
})
})
describe('_updateInterval', () => {
it('should use data-bs-interval from active element', () => {
fixtureEl.innerHTML = [
'<div id="myCarousel" class="carousel slide">',
' <div class="carousel-inner">',
' <div class="carousel-item active" data-bs-interval="2000">item 1</div>',
' <div class="carousel-item">item 2</div>',
' </div>',
'</div>'
].join('')
const carouselEl = fixtureEl.querySelector('#myCarousel')!
const carousel = new Carousel(carouselEl)
carousel._updateInterval()
expect(carousel._config.interval).toBe(2000)
})
it('should use data-tblr-interval from active element', () => {
fixtureEl.innerHTML = [
'<div id="myCarousel" class="carousel slide">',
' <div class="carousel-inner">',
' <div class="carousel-item active" data-tblr-interval="3000">item 1</div>',
' <div class="carousel-item">item 2</div>',
' </div>',
'</div>'
].join('')
const carouselEl = fixtureEl.querySelector('#myCarousel')!
const carousel = new Carousel(carouselEl)
carousel._updateInterval()
expect(carousel._config.interval).toBe(3000)
})
it('should fall back to defaultInterval if no data-interval', () => {
fixtureEl.innerHTML = [
'<div id="myCarousel" class="carousel slide">',
' <div class="carousel-inner">',
' <div class="carousel-item active">item 1</div>',
' </div>',
'</div>'
].join('')
const carouselEl = fixtureEl.querySelector('#myCarousel')!
const carousel = new Carousel(carouselEl)
carousel._updateInterval()
expect(carousel._config.interval).toBe(carousel._config.defaultInterval)
})
})
describe('_setActiveIndicatorElement', () => {
it('should update indicator with data-bs-slide-to', () => {
fixtureEl.innerHTML = [
'<div id="myCarousel" class="carousel slide">',
' <div class="carousel-indicators">',
' <button class="active" data-bs-slide-to="0" aria-current="true"></button>',
' <button data-bs-slide-to="1"></button>',
' </div>',
' <div class="carousel-inner">',
' <div class="carousel-item active">item 1</div>',
' <div class="carousel-item">item 2</div>',
' </div>',
'</div>'
].join('')
const carouselEl = fixtureEl.querySelector('#myCarousel')!
const carousel = new Carousel(carouselEl)
carousel._setActiveIndicatorElement(1)
const indicators = fixtureEl.querySelectorAll('[data-bs-slide-to]')
expect(indicators[0].classList.contains('active')).toBe(false)
expect(indicators[1].classList.contains('active')).toBe(true)
expect(indicators[1].getAttribute('aria-current')).toBe('true')
})
it('should update indicator with data-tblr-slide-to', () => {
fixtureEl.innerHTML = [
'<div id="myCarousel" class="carousel slide">',
' <div class="carousel-indicators">',
' <button class="active" data-tblr-slide-to="0" aria-current="true"></button>',
' <button data-tblr-slide-to="1"></button>',
' </div>',
' <div class="carousel-inner">',
' <div class="carousel-item active">item 1</div>',
' <div class="carousel-item">item 2</div>',
' </div>',
'</div>'
].join('')
const carouselEl = fixtureEl.querySelector('#myCarousel')!
const carousel = new Carousel(carouselEl)
carousel._setActiveIndicatorElement(1)
const indicators = fixtureEl.querySelectorAll('[data-tblr-slide-to]')
expect(indicators[0].classList.contains('active')).toBe(false)
expect(indicators[1].classList.contains('active')).toBe(true)
})
it('should skip if no indicators element', () => {
fixtureEl.innerHTML = [
'<div id="myCarousel" class="carousel slide">',
' <div class="carousel-inner">',
' <div class="carousel-item active">item 1</div>',
' </div>',
'</div>'
].join('')
const carouselEl = fixtureEl.querySelector('#myCarousel')!
const carousel = new Carousel(carouselEl)
expect(() => carousel._setActiveIndicatorElement(0)).not.toThrow()
})
})
describe('_maybeEnableCycle', () => {
it('should not cycle if ride is false', () => {
fixtureEl.innerHTML = '<div id="myCarousel" class="carousel slide"></div>'
const carousel = new Carousel('#myCarousel', { ride: false })
carousel._maybeEnableCycle()
expect(carousel._interval).toBeNull()
})
it('should defer cycle if sliding', () => {
fixtureEl.innerHTML = '<div id="myCarousel" class="carousel slide"></div>'
const carousel = new Carousel('#myCarousel', { ride: true })
carousel._isSliding = true
const spy = vi.spyOn(EventHandler, 'one')
carousel._maybeEnableCycle()
expect(spy).toHaveBeenCalled()
})
})
describe('wrap', () => {
it('should wrap from end to start', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<div id="myCarousel" class="carousel slide">',
' <div class="carousel-inner">',
' <div id="one" class="carousel-item active"></div>',
' <div id="two" class="carousel-item"></div>',
' <div id="three" class="carousel-item"></div>',
' </div>',
'</div>'
].join('')
const carouselEl = fixtureEl.querySelector('#myCarousel')!
const carousel = new Carousel(carouselEl, { wrap: true })
let slidCount = 0
carouselEl.addEventListener('slid.bs.carousel', () => {
slidCount++
const activeId = carouselEl.querySelector('.carousel-item.active')!.id
if (slidCount < 3) {
carousel.next()
return
}
// wrapped back to first
expect(activeId).toBe('one')
carousel.dispose()
resolve()
})
carousel.next()
})
})
})
describe('data-api', () => {
it('should auto-init via data-bs-ride="carousel"', () => {
fixtureEl.innerHTML = '<div id="myCarousel" class="carousel slide" data-bs-ride="carousel"></div>'
window.dispatchEvent(createEvent('load'))
const carouselEl = fixtureEl.querySelector('#myCarousel')!
const instance = Carousel.getInstance(carouselEl)
expect(instance).not.toBeNull()
instance!.dispose()
})
})
describe('data-tblr', () => {
it('should auto-init via data-tblr-ride="carousel"', () => {
fixtureEl.innerHTML = '<div id="myCarousel" class="carousel slide" data-tblr-ride="carousel"></div>'
window.dispatchEvent(createEvent('load'))
const carouselEl = fixtureEl.querySelector('#myCarousel')!
const instance = Carousel.getInstance(carouselEl)
expect(instance).not.toBeNull()
instance!.dispose()
})
it('should start cycling via data-tblr-ride', () => {
fixtureEl.innerHTML = '<div id="myCarousel" class="carousel slide" data-tblr-ride="carousel"></div>'
const carousel = new Carousel('#myCarousel')
expect(carousel._interval).not.toBeNull()
carousel.dispose()
})
it('should navigate via data-tblr-slide="next"', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<div id="myCarousel" class="carousel slide">',
' <div class="carousel-inner">',
' <div class="carousel-item active">item 1</div>',
' <div id="item2" class="carousel-item">item 2</div>',
' </div>',
' <button data-tblr-slide="next" data-bs-target="#myCarousel">Next</button>',
'</div>'
].join('')
const carouselEl = fixtureEl.querySelector('#myCarousel')!
const nextBtn = fixtureEl.querySelector('[data-tblr-slide="next"]') as HTMLElement
carouselEl.addEventListener('slid.bs.carousel', () => {
expect(fixtureEl.querySelector('#item2')!.classList.contains('active')).toBe(true)
resolve()
})
nextBtn.click()
})
})
it('should navigate via data-tblr-slide="prev"', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<div id="myCarousel" class="carousel slide">',
' <div class="carousel-inner">',
' <div id="item1" class="carousel-item">item 1</div>',
' <div class="carousel-item active">item 2</div>',
' </div>',
' <button data-tblr-slide="prev" data-bs-target="#myCarousel">Prev</button>',
'</div>'
].join('')
const carouselEl = fixtureEl.querySelector('#myCarousel')!
const prevBtn = fixtureEl.querySelector('[data-tblr-slide="prev"]') as HTMLElement
carouselEl.addEventListener('slid.bs.carousel', () => {
expect(fixtureEl.querySelector('#item1')!.classList.contains('active')).toBe(true)
resolve()
})
prevBtn.click()
})
})
it('should handle data-tblr-slide-to in indicators', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<div id="myCarousel" class="carousel slide">',
' <div class="carousel-indicators">',
' <button class="active" data-tblr-slide-to="0" data-bs-target="#myCarousel" aria-current="true"></button>',
' <button data-tblr-slide-to="1" data-bs-target="#myCarousel"></button>',
' </div>',
' <div class="carousel-inner">',
' <div class="carousel-item active">item 1</div>',
' <div id="item2" class="carousel-item">item 2</div>',
' </div>',
'</div>'
].join('')
const carouselEl = fixtureEl.querySelector('#myCarousel')!
const trigger = fixtureEl.querySelectorAll('[data-tblr-slide-to]')[1] as HTMLElement
carouselEl.addEventListener('slid.bs.carousel', () => {
expect(fixtureEl.querySelector('#item2')!.classList.contains('active')).toBe(true)
resolve()
})
trigger.click()
})
})
})
})

View File

@@ -0,0 +1,857 @@
import { describe, it, expect, beforeAll, afterEach, vi } from 'vitest'
import Collapse from '../../src/bootstrap/collapse'
import EventHandler from '../../src/bootstrap/dom/event-handler'
import { clearFixture, getFixture } from '../helpers/fixture'
describe('Collapse', () => {
let fixtureEl: HTMLElement
beforeAll(() => {
fixtureEl = getFixture()
})
afterEach(() => {
clearFixture()
vi.restoreAllMocks()
})
describe('VERSION', () => {
it('should return plugin version', () => {
expect(typeof Collapse.VERSION).toBe('string')
})
})
describe('Default', () => {
it('should return plugin default config', () => {
expect(typeof Collapse.Default).toBe('object')
})
})
describe('DATA_KEY', () => {
it('should return plugin data key', () => {
expect(Collapse.DATA_KEY).toBe('bs.collapse')
})
})
describe('constructor', () => {
it('should accept element as CSS selector or DOM element', () => {
fixtureEl.innerHTML = '<div class="my-collapse"></div>'
const collapseEl = fixtureEl.querySelector('div.my-collapse')!
const collapseBySelector = new Collapse('div.my-collapse')
const collapseByElement = new Collapse(collapseEl)
expect(collapseBySelector._element).toBe(collapseEl)
expect(collapseByElement._element).toBe(collapseEl)
})
it('should allow DOM element in parent config', () => {
fixtureEl.innerHTML = [
'<div class="my-collapse">',
' <div class="item">',
' <a data-bs-toggle="collapse" href="#">Toggle</a>',
' <div class="collapse">Lorem ipsum</div>',
' </div>',
'</div>'
].join('')
const collapseEl = fixtureEl.querySelector('div.collapse')!
const myCollapseEl = fixtureEl.querySelector('.my-collapse')!
const collapse = new Collapse(collapseEl, { parent: myCollapseEl })
expect(collapse._config.parent).toBe(myCollapseEl)
})
it('should allow string selector in parent config', () => {
fixtureEl.innerHTML = [
'<div class="my-collapse">',
' <div class="item">',
' <a data-bs-toggle="collapse" href="#">Toggle</a>',
' <div class="collapse">Lorem ipsum</div>',
' </div>',
'</div>'
].join('')
const collapseEl = fixtureEl.querySelector('div.collapse')!
const myCollapseEl = fixtureEl.querySelector('.my-collapse')!
const collapse = new Collapse(collapseEl, { parent: 'div.my-collapse' })
expect(collapse._config.parent).toBe(myCollapseEl)
})
})
describe('toggle', () => {
it('should call show if not shown', () => {
fixtureEl.innerHTML = '<div></div>'
const collapseEl = fixtureEl.querySelector('div')!
const collapse = new Collapse(collapseEl)
const spy = vi.spyOn(collapse, 'show')
collapse.toggle()
expect(spy).toHaveBeenCalled()
})
it('should call hide if shown', () => {
fixtureEl.innerHTML = '<div class="show"></div>'
const collapseEl = fixtureEl.querySelector('.show')!
const collapse = new Collapse(collapseEl, { toggle: false })
const spy = vi.spyOn(collapse, 'hide')
collapse.toggle()
expect(spy).toHaveBeenCalled()
})
it('should collapse children with parent', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<div class="my-collapse">',
' <div class="item">',
' <a data-bs-toggle="collapse" href="#">Toggle 1</a>',
' <div id="collapse1" class="collapse show">Lorem ipsum 1</div>',
' </div>',
' <div class="item">',
' <a data-bs-toggle="collapse" href="#">Toggle 2</a>',
' <div id="collapse2" class="collapse">Lorem ipsum 2</div>',
' </div>',
'</div>'
].join('')
const parent = fixtureEl.querySelector('.my-collapse')!
const collapseEl1 = fixtureEl.querySelector('#collapse1')!
const collapseEl2 = fixtureEl.querySelector('#collapse2')!
const collapseList = Array.from(fixtureEl.querySelectorAll('.collapse'))
.map(el => new Collapse(el, { parent, toggle: false }))
collapseEl2.addEventListener('shown.bs.collapse', () => {
expect(collapseEl2.classList.contains('show')).toBe(true)
expect(collapseEl1.classList.contains('show')).toBe(false)
resolve()
})
collapseList[1].toggle()
})
})
})
describe('show', () => {
it('should do nothing if transitioning', () => {
fixtureEl.innerHTML = '<div></div>'
const spy = vi.spyOn(EventHandler, 'trigger')
const collapseEl = fixtureEl.querySelector('div')!
const collapse = new Collapse(collapseEl, { toggle: false })
collapse._isTransitioning = true
collapse.show()
expect(spy).not.toHaveBeenCalled()
})
it('should do nothing if already shown', () => {
fixtureEl.innerHTML = '<div class="show"></div>'
const spy = vi.spyOn(EventHandler, 'trigger')
const collapseEl = fixtureEl.querySelector('div')!
const collapse = new Collapse(collapseEl, { toggle: false })
collapse.show()
expect(spy).not.toHaveBeenCalled()
})
it('should show a collapsed element', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = '<div class="collapse" style="height: 0px;"></div>'
const collapseEl = fixtureEl.querySelector('div')!
const collapse = new Collapse(collapseEl, { toggle: false })
collapseEl.addEventListener('show.bs.collapse', () => {
expect(collapseEl.style.height).toBe('0px')
})
collapseEl.addEventListener('shown.bs.collapse', () => {
expect(collapseEl.classList.contains('show')).toBe(true)
expect(collapseEl.style.height).toBe('')
resolve()
})
collapse.show()
})
})
it('should show a collapsed element on width', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = '<div class="collapse collapse-horizontal" style="width: 0px;"></div>'
const collapseEl = fixtureEl.querySelector('div')!
const collapse = new Collapse(collapseEl, { toggle: false })
collapseEl.addEventListener('show.bs.collapse', () => {
expect(collapseEl.style.width).toBe('0px')
})
collapseEl.addEventListener('shown.bs.collapse', () => {
expect(collapseEl.classList.contains('show')).toBe(true)
expect(collapseEl.style.width).toBe('')
resolve()
})
collapse.show()
})
})
it('should collapse only the first collapse', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<div class="card" id="accordion1">',
' <div id="collapse1" class="collapse"></div>',
'</div>',
'<div class="card" id="accordion2">',
' <div id="collapse2" class="collapse show"></div>',
'</div>'
].join('')
const el1 = fixtureEl.querySelector('#collapse1')!
const el2 = fixtureEl.querySelector('#collapse2')!
const collapse = new Collapse(el1, { toggle: false })
el1.addEventListener('shown.bs.collapse', () => {
expect(el1.classList.contains('show')).toBe(true)
expect(el2.classList.contains('show')).toBe(true)
resolve()
})
collapse.show()
})
})
it('should handle toggling children siblings in accordion', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<div id="parentGroup" class="accordion">',
' <div class="accordion-header">',
' <button data-bs-target="#parentContent" data-bs-toggle="collapse">Parent</button>',
' </div>',
' <div id="parentContent" class="accordion-collapse collapse" data-bs-parent="#parentGroup">',
' <div class="accordion-body">',
' <div id="childGroup" class="accordion">',
' <div class="accordion-item">',
' <button data-bs-target="#childContent1" data-bs-toggle="collapse">Child 1</button>',
' <div id="childContent1" class="accordion-collapse collapse" data-bs-parent="#childGroup">content</div>',
' </div>',
' <div class="accordion-item">',
' <button data-bs-target="#childContent2" data-bs-toggle="collapse">Child 2</button>',
' <div id="childContent2" class="accordion-collapse collapse" data-bs-parent="#childGroup">content</div>',
' </div>',
' </div>',
' </div>',
' </div>',
'</div>'
].join('')
const el = (s: string) => fixtureEl.querySelector(s) as HTMLElement
const parentBtn = el('[data-bs-target="#parentContent"]')
const childBtn1 = el('[data-bs-target="#childContent1"]')
const childBtn2 = el('[data-bs-target="#childContent2"]')
const parentCollapseEl = el('#parentContent')
const childCollapseEl1 = el('#childContent1')
const childCollapseEl2 = el('#childContent2')
parentCollapseEl.addEventListener('shown.bs.collapse', () => {
expect(parentCollapseEl.classList.contains('show')).toBe(true)
childBtn1.click()
})
childCollapseEl1.addEventListener('shown.bs.collapse', () => {
expect(childCollapseEl1.classList.contains('show')).toBe(true)
childBtn2.click()
})
childCollapseEl2.addEventListener('shown.bs.collapse', () => {
expect(childCollapseEl2.classList.contains('show')).toBe(true)
expect(childCollapseEl1.classList.contains('show')).toBe(false)
resolve()
})
parentBtn.click()
})
})
it('should not show if active children are transitioning', () => {
fixtureEl.innerHTML = [
'<div id="accordion">',
' <div id="collapse1" class="collapse show" data-bs-parent="#accordion"></div>',
' <div id="collapse2" class="collapse" data-bs-parent="#accordion"></div>',
'</div>'
].join('')
const el1 = fixtureEl.querySelector('#collapse1')!
const el2 = fixtureEl.querySelector('#collapse2')!
const collapse1 = new Collapse(el1, { toggle: false })
const collapse2 = new Collapse(el2, { toggle: false })
collapse1._isTransitioning = true
const spy = vi.spyOn(EventHandler, 'trigger')
collapse2.show()
expect(spy).not.toHaveBeenCalled()
})
it('should not fire shown when show is prevented', () => {
return new Promise<void>((resolve, reject) => {
fixtureEl.innerHTML = '<div class="collapse"></div>'
const collapseEl = fixtureEl.querySelector('div')!
const collapse = new Collapse(collapseEl, { toggle: false })
collapseEl.addEventListener('show.bs.collapse', event => {
event.preventDefault()
setTimeout(() => {
resolve()
}, 10)
})
collapseEl.addEventListener('shown.bs.collapse', () => {
reject(new Error('should not fire shown'))
})
collapse.show()
})
})
})
describe('hide', () => {
it('should do nothing if transitioning', () => {
fixtureEl.innerHTML = '<div></div>'
const spy = vi.spyOn(EventHandler, 'trigger')
const collapseEl = fixtureEl.querySelector('div')!
const collapse = new Collapse(collapseEl, { toggle: false })
collapse._isTransitioning = true
collapse.hide()
expect(spy).not.toHaveBeenCalled()
})
it('should do nothing if not shown', () => {
fixtureEl.innerHTML = '<div></div>'
const spy = vi.spyOn(EventHandler, 'trigger')
const collapseEl = fixtureEl.querySelector('div')!
const collapse = new Collapse(collapseEl, { toggle: false })
collapse.hide()
expect(spy).not.toHaveBeenCalled()
})
it('should hide a collapse element', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = '<div class="collapse show"></div>'
const collapseEl = fixtureEl.querySelector('div')!
const collapse = new Collapse(collapseEl, { toggle: false })
collapseEl.addEventListener('hidden.bs.collapse', () => {
expect(collapseEl.classList.contains('show')).toBe(false)
expect(collapseEl.style.height).toBe('')
resolve()
})
collapse.hide()
})
})
it('should not fire hidden when hide is prevented', () => {
return new Promise<void>((resolve, reject) => {
fixtureEl.innerHTML = '<div class="collapse show"></div>'
const collapseEl = fixtureEl.querySelector('div')!
const collapse = new Collapse(collapseEl, { toggle: false })
collapseEl.addEventListener('hide.bs.collapse', event => {
event.preventDefault()
setTimeout(resolve, 10)
})
collapseEl.addEventListener('hidden.bs.collapse', () => {
reject(new Error('should not fire hidden'))
})
collapse.hide()
})
})
})
describe('dispose', () => {
it('should destroy a collapse', () => {
fixtureEl.innerHTML = '<div class="collapse show"></div>'
const collapseEl = fixtureEl.querySelector('div')!
const collapse = new Collapse(collapseEl, { toggle: false })
expect(Collapse.getInstance(collapseEl)).toBe(collapse)
collapse.dispose()
expect(Collapse.getInstance(collapseEl)).toBeNull()
})
})
describe('data-api', () => {
it('should prevent url change on nested elements', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<a role="button" data-bs-toggle="collapse" class="collapsed" href="#collapse">',
' <span id="nested"></span>',
'</a>',
'<div id="collapse" class="collapse"></div>'
].join('')
const triggerEl = fixtureEl.querySelector('a')!
const nestedTriggerEl = fixtureEl.querySelector('#nested')!
const spy = vi.spyOn(Event.prototype, 'preventDefault')
triggerEl.addEventListener('click', event => {
expect((event.target as Element).isEqualNode(nestedTriggerEl)).toBe(true)
expect(spy).toHaveBeenCalled()
resolve()
})
nestedTriggerEl.click()
})
})
it('should show multiple collapsed elements', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<a role="button" data-bs-toggle="collapse" class="collapsed" href=".multi"></a>',
'<div id="collapse1" class="collapse multi"></div>',
'<div id="collapse2" class="collapse multi"></div>'
].join('')
const trigger = fixtureEl.querySelector('a')!
const collapse1 = fixtureEl.querySelector('#collapse1')!
const collapse2 = fixtureEl.querySelector('#collapse2')!
collapse2.addEventListener('shown.bs.collapse', () => {
expect(trigger.getAttribute('aria-expanded')).toBe('true')
expect(trigger.classList.contains('collapsed')).toBe(false)
expect(collapse1.classList.contains('show')).toBe(true)
expect(collapse2.classList.contains('show')).toBe(true)
resolve()
})
trigger.click()
})
})
it('should hide multiple collapsed elements', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<a role="button" data-bs-toggle="collapse" href=".multi"></a>',
'<div id="collapse1" class="collapse multi show"></div>',
'<div id="collapse2" class="collapse multi show"></div>'
].join('')
const trigger = fixtureEl.querySelector('a')!
const collapse1 = fixtureEl.querySelector('#collapse1')!
const collapse2 = fixtureEl.querySelector('#collapse2')!
collapse2.addEventListener('hidden.bs.collapse', () => {
expect(trigger.getAttribute('aria-expanded')).toBe('false')
expect(trigger.classList.contains('collapsed')).toBe(true)
expect(collapse1.classList.contains('show')).toBe(false)
expect(collapse2.classList.contains('show')).toBe(false)
resolve()
})
trigger.click()
})
})
it('should show collapse via data-tblr-target', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<a role="button" data-bs-toggle="collapse" class="collapsed" data-tblr-target="#test1"></a>',
'<div id="test1" class="collapse"></div>'
].join('')
const trigger = fixtureEl.querySelector('a') as HTMLElement
const collapseEl = fixtureEl.querySelector('#test1')!
collapseEl.addEventListener('shown.bs.collapse', () => {
expect(collapseEl.classList.contains('show')).toBe(true)
expect(trigger.classList.contains('collapsed')).toBe(false)
expect(trigger.getAttribute('aria-expanded')).toBe('true')
resolve()
})
trigger.click()
})
})
it('should hide collapse via data-tblr-target', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<a role="button" data-bs-toggle="collapse" data-tblr-target="#test1"></a>',
'<div id="test1" class="collapse show"></div>'
].join('')
const trigger = fixtureEl.querySelector('a') as HTMLElement
const collapseEl = fixtureEl.querySelector('#test1')!
collapseEl.addEventListener('hidden.bs.collapse', () => {
expect(collapseEl.classList.contains('show')).toBe(false)
expect(trigger.classList.contains('collapsed')).toBe(true)
expect(trigger.getAttribute('aria-expanded')).toBe('false')
resolve()
})
trigger.click()
})
})
it('should remove collapsed class from trigger on show', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<a id="link1" role="button" data-bs-toggle="collapse" class="collapsed" href="#" data-bs-target="#test1"></a>',
'<a id="link2" role="button" data-bs-toggle="collapse" class="collapsed" href="#" data-bs-target="#test1"></a>',
'<div id="test1"></div>'
].join('')
const link1 = fixtureEl.querySelector('#link1')!
const link2 = fixtureEl.querySelector('#link2')!
const collapseEl = fixtureEl.querySelector('#test1')!
collapseEl.addEventListener('shown.bs.collapse', () => {
expect(link1.getAttribute('aria-expanded')).toBe('true')
expect(link2.getAttribute('aria-expanded')).toBe('true')
expect(link1.classList.contains('collapsed')).toBe(false)
expect(link2.classList.contains('collapsed')).toBe(false)
resolve()
})
;(link1 as HTMLElement).click()
})
})
it('should add collapsed class to trigger on hide', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<a id="link1" role="button" data-bs-toggle="collapse" href="#" data-bs-target="#test1"></a>',
'<a id="link2" role="button" data-bs-toggle="collapse" href="#" data-bs-target="#test1"></a>',
'<div id="test1" class="show"></div>'
].join('')
const link1 = fixtureEl.querySelector('#link1')!
const link2 = fixtureEl.querySelector('#link2')!
const collapseEl = fixtureEl.querySelector('#test1')!
collapseEl.addEventListener('hidden.bs.collapse', () => {
expect(link1.getAttribute('aria-expanded')).toBe('false')
expect(link2.getAttribute('aria-expanded')).toBe('false')
expect(link1.classList.contains('collapsed')).toBe(true)
expect(link2.classList.contains('collapsed')).toBe(true)
resolve()
})
;(link1 as HTMLElement).click()
})
})
it('should allow accordion with non-card children', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<div id="accordion">',
' <div class="item">',
' <a id="linkTrigger" data-bs-toggle="collapse" href="#collapseOne"></a>',
' <div id="collapseOne" class="collapse" data-bs-parent="#accordion"></div>',
' </div>',
' <div class="item">',
' <a id="linkTriggerTwo" data-bs-toggle="collapse" href="#collapseTwo"></a>',
' <div id="collapseTwo" class="collapse show" data-bs-parent="#accordion"></div>',
' </div>',
'</div>'
].join('')
const trigger = fixtureEl.querySelector('#linkTrigger') as HTMLElement
const triggerTwo = fixtureEl.querySelector('#linkTriggerTwo') as HTMLElement
const collapseOne = fixtureEl.querySelector('#collapseOne')!
const collapseTwo = fixtureEl.querySelector('#collapseTwo')!
collapseOne.addEventListener('shown.bs.collapse', () => {
expect(collapseOne.classList.contains('show')).toBe(true)
expect(collapseTwo.classList.contains('show')).toBe(false)
collapseTwo.addEventListener('shown.bs.collapse', () => {
expect(collapseOne.classList.contains('show')).toBe(false)
expect(collapseTwo.classList.contains('show')).toBe(true)
resolve()
})
triggerTwo.click()
})
trigger.click()
})
})
it('should not prevent event for input', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<input type="checkbox" data-bs-toggle="collapse" data-bs-target="#collapsediv1">',
'<div id="collapsediv1"></div>'
].join('')
const target = fixtureEl.querySelector('input') as HTMLInputElement
const collapseEl = fixtureEl.querySelector('#collapsediv1')!
collapseEl.addEventListener('shown.bs.collapse', () => {
expect(collapseEl.classList.contains('show')).toBe(true)
expect(target.checked).toBe(true)
resolve()
})
target.click()
})
})
it('should allow accordion with nested elements', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<div id="accordion">',
' <div class="row">',
' <div class="col-lg-6">',
' <div class="item">',
' <a id="linkTrigger" data-bs-toggle="collapse" href="#collapseOne"></a>',
' <div id="collapseOne" class="collapse" data-bs-parent="#accordion"></div>',
' </div>',
' </div>',
' <div class="col-lg-6">',
' <div class="item">',
' <a id="linkTriggerTwo" data-bs-toggle="collapse" href="#collapseTwo"></a>',
' <div id="collapseTwo" class="collapse show" data-bs-parent="#accordion"></div>',
' </div>',
' </div>',
' </div>',
'</div>'
].join('')
const triggerEl = fixtureEl.querySelector('#linkTrigger') as HTMLElement
const triggerTwoEl = fixtureEl.querySelector('#linkTriggerTwo') as HTMLElement
const collapseOneEl = fixtureEl.querySelector('#collapseOne')!
const collapseTwoEl = fixtureEl.querySelector('#collapseTwo')!
collapseOneEl.addEventListener('shown.bs.collapse', () => {
expect(collapseOneEl.classList.contains('show')).toBe(true)
expect(triggerEl.classList.contains('collapsed')).toBe(false)
expect(triggerEl.getAttribute('aria-expanded')).toBe('true')
expect(collapseTwoEl.classList.contains('show')).toBe(false)
expect(triggerTwoEl.classList.contains('collapsed')).toBe(true)
expect(triggerTwoEl.getAttribute('aria-expanded')).toBe('false')
collapseTwoEl.addEventListener('shown.bs.collapse', () => {
expect(collapseOneEl.classList.contains('show')).toBe(false)
expect(triggerEl.classList.contains('collapsed')).toBe(true)
expect(triggerEl.getAttribute('aria-expanded')).toBe('false')
expect(collapseTwoEl.classList.contains('show')).toBe(true)
expect(triggerTwoEl.classList.contains('collapsed')).toBe(false)
expect(triggerTwoEl.getAttribute('aria-expanded')).toBe('true')
resolve()
})
triggerTwoEl.click()
})
triggerEl.click()
})
})
it('should collapse accordion children but not nested accordion', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<div id="accordion">',
' <div class="item">',
' <a id="linkTrigger" data-bs-toggle="collapse" href="#collapseOne"></a>',
' <div id="collapseOne" data-bs-parent="#accordion" class="collapse">',
' <div id="nestedAccordion">',
' <div class="item">',
' <a id="nestedLinkTrigger" data-bs-toggle="collapse" href="#nestedCollapseOne"></a>',
' <div id="nestedCollapseOne" data-bs-parent="#nestedAccordion" class="collapse"></div>',
' </div>',
' </div>',
' </div>',
' </div>',
' <div class="item">',
' <a id="linkTriggerTwo" data-bs-toggle="collapse" href="#collapseTwo"></a>',
' <div id="collapseTwo" data-bs-parent="#accordion" class="collapse show"></div>',
' </div>',
'</div>'
].join('')
const trigger = fixtureEl.querySelector('#linkTrigger') as HTMLElement
const triggerTwo = fixtureEl.querySelector('#linkTriggerTwo') as HTMLElement
const nestedTrigger = fixtureEl.querySelector('#nestedLinkTrigger') as HTMLElement
const collapseOne = fixtureEl.querySelector('#collapseOne')!
const collapseTwo = fixtureEl.querySelector('#collapseTwo')!
const nestedCollapseOne = fixtureEl.querySelector('#nestedCollapseOne')!
function handlerCollapseOne() {
expect(collapseOne.classList.contains('show')).toBe(true)
expect(collapseTwo.classList.contains('show')).toBe(false)
expect(nestedCollapseOne.classList.contains('show')).toBe(false)
nestedCollapseOne.addEventListener('shown.bs.collapse', handlerNestedCollapseOne)
nestedTrigger.click()
collapseOne.removeEventListener('shown.bs.collapse', handlerCollapseOne)
}
function handlerNestedCollapseOne() {
expect(collapseOne.classList.contains('show')).toBe(true)
expect(collapseTwo.classList.contains('show')).toBe(false)
expect(nestedCollapseOne.classList.contains('show')).toBe(true)
collapseTwo.addEventListener('shown.bs.collapse', () => {
expect(collapseOne.classList.contains('show')).toBe(false)
expect(collapseTwo.classList.contains('show')).toBe(true)
expect(nestedCollapseOne.classList.contains('show')).toBe(true)
resolve()
})
triggerTwo.click()
nestedCollapseOne.removeEventListener('shown.bs.collapse', handlerNestedCollapseOne)
}
collapseOne.addEventListener('shown.bs.collapse', handlerCollapseOne)
trigger.click()
})
})
})
describe('getInstance', () => {
it('should return collapse instance', () => {
fixtureEl.innerHTML = '<div></div>'
const div = fixtureEl.querySelector('div')!
const collapse = new Collapse(div)
expect(Collapse.getInstance(div)).toBe(collapse)
expect(Collapse.getInstance(div)).toBeInstanceOf(Collapse)
})
it('should return null when there is no collapse instance', () => {
fixtureEl.innerHTML = '<div></div>'
const div = fixtureEl.querySelector('div')!
expect(Collapse.getInstance(div)).toBeNull()
})
})
describe('getOrCreateInstance', () => {
it('should return collapse instance', () => {
fixtureEl.innerHTML = '<div></div>'
const div = fixtureEl.querySelector('div')!
const collapse = new Collapse(div)
expect(Collapse.getOrCreateInstance(div)).toBe(collapse)
expect(Collapse.getOrCreateInstance(div)).toBeInstanceOf(Collapse)
})
it('should return new instance when there is no collapse instance', () => {
fixtureEl.innerHTML = '<div></div>'
const div = fixtureEl.querySelector('div')!
expect(Collapse.getInstance(div)).toBeNull()
expect(Collapse.getOrCreateInstance(div)).toBeInstanceOf(Collapse)
})
it('should return new instance with given configuration', () => {
fixtureEl.innerHTML = '<div></div>'
const div = fixtureEl.querySelector('div')!
const collapse = Collapse.getOrCreateInstance(div, { toggle: false })
expect(collapse).toBeInstanceOf(Collapse)
expect(collapse._config.toggle).toBe(false)
})
it('should return existing instance ignoring new configuration', () => {
fixtureEl.innerHTML = '<div></div>'
const div = fixtureEl.querySelector('div')!
const collapse = new Collapse(div, { toggle: false })
const collapse2 = Collapse.getOrCreateInstance(div, { toggle: true })
expect(collapse2).toBe(collapse)
expect(collapse2._config.toggle).toBe(false)
})
})
describe('data-tblr-toggle', () => {
it('should show collapse via data-tblr-toggle="collapse"', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<button data-tblr-toggle="collapse" data-bs-target="#test1">Toggle</button>',
'<div id="test1" class="collapse">Content</div>'
].join('')
const target = fixtureEl.querySelector('#test1')!
const btn = fixtureEl.querySelector('[data-tblr-toggle="collapse"]') as HTMLElement
target.addEventListener('shown.bs.collapse', () => {
expect(target.classList.contains('show')).toBe(true)
resolve()
})
btn.click()
})
})
it('should hide collapse via data-tblr-toggle="collapse"', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<button data-tblr-toggle="collapse" data-bs-target="#test1">Toggle</button>',
'<div id="test1" class="collapse show">Content</div>'
].join('')
const target = fixtureEl.querySelector('#test1')!
const btn = fixtureEl.querySelector('[data-tblr-toggle="collapse"]') as HTMLElement
target.addEventListener('hidden.bs.collapse', () => {
expect(target.classList.contains('show')).toBe(false)
resolve()
})
btn.click()
})
})
it('should show collapse via data-tblr-toggle with data-tblr-target', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<button data-tblr-toggle="collapse" data-tblr-target="#test1">Toggle</button>',
'<div id="test1" class="collapse">Content</div>'
].join('')
const target = fixtureEl.querySelector('#test1')!
const btn = fixtureEl.querySelector('[data-tblr-toggle="collapse"]') as HTMLElement
target.addEventListener('shown.bs.collapse', () => {
expect(target.classList.contains('show')).toBe(true)
resolve()
})
btn.click()
})
})
})
})

View File

@@ -0,0 +1,182 @@
import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest'
import Data from '../../../src/bootstrap/dom/data'
import { clearFixture, getFixture } from '../../helpers/fixture'
describe('Data', () => {
const TEST_KEY = 'bs.test'
const UNKNOWN_KEY = 'bs.unknown'
const TEST_DATA = { test: 'bsData' }
let fixtureEl: HTMLElement
let div: HTMLElement
beforeAll(() => {
fixtureEl = getFixture()
})
beforeEach(() => {
fixtureEl.innerHTML = '<div></div>'
div = fixtureEl.querySelector('div')!
})
afterEach(() => {
Data.remove(div, TEST_KEY)
clearFixture()
})
it('should return null for unknown elements', () => {
Data.set(div, TEST_KEY, { ...TEST_DATA })
expect(Data.get(document.createElement('div'), TEST_KEY)).toBeNull()
})
it('should return null for unknown keys', () => {
Data.set(div, TEST_KEY, { ...TEST_DATA })
expect(Data.get(div, UNKNOWN_KEY)).toBeNull()
})
it('should store data for an element with a given key and return it', () => {
const data = { ...TEST_DATA }
Data.set(div, TEST_KEY, data)
expect(Data.get(div, TEST_KEY)).toEqual(data)
})
it('should overwrite data if something is already stored', () => {
const data = { ...TEST_DATA }
const copy = { ...data }
Data.set(div, TEST_KEY, data)
Data.set(div, TEST_KEY, copy)
expect(Data.get(div, TEST_KEY)).not.toBe(data)
expect(Data.get(div, TEST_KEY)).toBe(copy)
})
it('should do nothing when an element has nothing stored', () => {
Data.remove(div, TEST_KEY)
})
it('should remove nothing for an unknown key', () => {
const data = { ...TEST_DATA }
Data.set(div, TEST_KEY, data)
Data.remove(div, UNKNOWN_KEY)
expect(Data.get(div, TEST_KEY)).toEqual(data)
})
it('should remove data for a given key', () => {
const data = { ...TEST_DATA }
Data.set(div, TEST_KEY, data)
Data.remove(div, TEST_KEY)
expect(Data.get(div, TEST_KEY)).toBeNull()
})
it('should console.error if called with multiple keys', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
Data.set(div, TEST_KEY, { ...TEST_DATA })
Data.set(div, UNKNOWN_KEY, { ...TEST_DATA })
expect(spy).toHaveBeenCalled()
expect(Data.get(div, UNKNOWN_KEY)).toBeNull()
spy.mockRestore()
})
it('should include the bound key name in error message', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
Data.set(div, TEST_KEY, { ...TEST_DATA })
Data.set(div, UNKNOWN_KEY, { ...TEST_DATA })
expect(spy).toHaveBeenCalledWith(
expect.stringContaining(TEST_KEY)
)
spy.mockRestore()
})
it('should not modify the first instance when a second key is rejected', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
const original = { ...TEST_DATA }
Data.set(div, TEST_KEY, original)
Data.set(div, UNKNOWN_KEY, { other: true })
expect(Data.get(div, TEST_KEY)).toBe(original)
spy.mockRestore()
})
it('should handle set-remove-set cycle on the same element', () => {
const first = { v: 1 }
const second = { v: 2 }
Data.set(div, TEST_KEY, first)
Data.remove(div, TEST_KEY)
Data.set(div, TEST_KEY, second)
expect(Data.get(div, TEST_KEY)).toBe(second)
})
it('should handle multiple elements independently', () => {
const div2 = document.createElement('div')
const data1 = { el: 1 }
const data2 = { el: 2 }
Data.set(div, TEST_KEY, data1)
Data.set(div2, TEST_KEY, data2)
expect(Data.get(div, TEST_KEY)).toBe(data1)
expect(Data.get(div2, TEST_KEY)).toBe(data2)
Data.remove(div, TEST_KEY)
expect(Data.get(div, TEST_KEY)).toBeNull()
expect(Data.get(div2, TEST_KEY)).toBe(data2)
Data.remove(div2, TEST_KEY)
})
it('should return null for an element that was never stored', () => {
const fresh = document.createElement('span')
expect(Data.get(fresh, TEST_KEY)).toBeNull()
})
it('should clean up element entry when last key is removed', () => {
Data.set(div, TEST_KEY, { ...TEST_DATA })
Data.remove(div, TEST_KEY)
Data.set(div, UNKNOWN_KEY, { other: true })
expect(Data.get(div, UNKNOWN_KEY)).toEqual({ other: true })
Data.remove(div, UNKNOWN_KEY)
})
it('should allow overwriting the same key multiple times', () => {
const a = { v: 'a' }
const b = { v: 'b' }
const c = { v: 'c' }
Data.set(div, TEST_KEY, a)
Data.set(div, TEST_KEY, b)
Data.set(div, TEST_KEY, c)
expect(Data.get(div, TEST_KEY)).toBe(c)
})
it('should return the exact reference that was stored', () => {
const ref = { unique: Symbol('test') }
Data.set(div, TEST_KEY, ref)
expect(Data.get(div, TEST_KEY)).toBe(ref)
})
})

View File

@@ -0,0 +1,460 @@
import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest'
import EventHandler from '../../../src/bootstrap/dom/event-handler'
import { clearFixture, getFixture } from '../../helpers/fixture'
describe('EventHandler', () => {
let fixtureEl: HTMLElement
let div: HTMLElement
beforeAll(() => {
fixtureEl = getFixture()
})
beforeEach(() => {
fixtureEl.innerHTML = '<div><span><button>Click</button></span></div>'
div = fixtureEl.querySelector('div')!
})
afterEach(() => {
EventHandler.off(div, 'click')
clearFixture()
})
describe('on', () => {
it('should bind an event listener to an element', () => {
const handler = vi.fn()
EventHandler.on(div, 'click', handler)
div.click()
expect(handler).toHaveBeenCalledOnce()
})
it('should receive the Event object', () => {
const handler = vi.fn()
EventHandler.on(div, 'click', handler)
div.click()
expect(handler).toHaveBeenCalledWith(expect.any(Event))
})
it('should bind multiple different events on the same element', () => {
const clickHandler = vi.fn()
const focusHandler = vi.fn()
EventHandler.on(div, 'click', clickHandler)
EventHandler.on(div, 'focusin', focusHandler)
div.click()
expect(clickHandler).toHaveBeenCalledOnce()
expect(focusHandler).not.toHaveBeenCalled()
EventHandler.off(div, 'focusin')
})
it('should not add duplicate handlers for the same callback', () => {
const handler = vi.fn()
EventHandler.on(div, 'click', handler)
EventHandler.on(div, 'click', handler)
div.click()
expect(handler).toHaveBeenCalledOnce()
})
it('should do nothing if element is null', () => {
expect(() => {
EventHandler.on(null, 'click', vi.fn())
}).not.toThrow()
})
it('should handle a custom (non-native) event', () => {
const handler = vi.fn()
EventHandler.on(div, 'my.custom.event', handler)
EventHandler.trigger(div, 'my.custom.event')
expect(handler).toHaveBeenCalledOnce()
EventHandler.off(div, 'my.custom.event')
})
})
describe('one', () => {
it('should call the handler only once', () => {
const handler = vi.fn()
EventHandler.one(div, 'click', handler)
div.click()
div.click()
expect(handler).toHaveBeenCalledOnce()
})
it('should unbind the handler after first call', () => {
const handler = vi.fn()
EventHandler.one(div, 'click', handler)
div.click()
expect(handler).toHaveBeenCalledOnce()
div.click()
expect(handler).toHaveBeenCalledOnce()
})
})
describe('off', () => {
it('should remove a specific handler', () => {
const handler = vi.fn()
EventHandler.on(div, 'click', handler)
EventHandler.off(div, 'click', handler)
div.click()
expect(handler).not.toHaveBeenCalled()
})
it('should remove all handlers for an event type', () => {
const handler1 = vi.fn()
const handler2 = vi.fn()
EventHandler.on(div, 'click', handler1)
EventHandler.on(div, 'click', handler2)
EventHandler.off(div, 'click')
div.click()
expect(handler1).not.toHaveBeenCalled()
expect(handler2).not.toHaveBeenCalled()
})
it('should do nothing if element is null', () => {
expect(() => {
EventHandler.off(null, 'click')
}).not.toThrow()
})
it('should do nothing if no handlers are registered', () => {
const fresh = document.createElement('div')
expect(() => {
EventHandler.off(fresh, 'click', vi.fn())
}).not.toThrow()
})
it('should only remove the specified handler, keeping others', () => {
const handler1 = vi.fn()
const handler2 = vi.fn()
EventHandler.on(div, 'click', handler1)
EventHandler.on(div, 'click', handler2)
EventHandler.off(div, 'click', handler1)
div.click()
expect(handler1).not.toHaveBeenCalled()
expect(handler2).toHaveBeenCalledOnce()
EventHandler.off(div, 'click', handler2)
})
})
describe('trigger', () => {
it('should trigger a native event', () => {
const handler = vi.fn()
EventHandler.on(div, 'click', handler)
EventHandler.trigger(div, 'click')
expect(handler).toHaveBeenCalledOnce()
})
it('should return the dispatched Event object', () => {
const evt = EventHandler.trigger(div, 'click')
expect(evt).toBeInstanceOf(Event)
expect(evt!.type).toBe('click')
})
it('should create a bubbling, cancelable event', () => {
const evt = EventHandler.trigger(div, 'click')
expect(evt!.bubbles).toBe(true)
expect(evt!.cancelable).toBe(true)
})
it('should return null if element is null', () => {
const evt = EventHandler.trigger(null, 'click')
expect(evt).toBeNull()
})
it('should hydrate the event with extra args', () => {
let receivedEvent: Event | null = null
EventHandler.on(div, 'show.bs.modal', (event: unknown) => {
receivedEvent = event as Event
})
EventHandler.trigger(div, 'show.bs.modal', { relatedTarget: div })
expect((receivedEvent as unknown as Record<string, unknown>).relatedTarget).toBe(div)
EventHandler.off(div, 'show.bs.modal')
})
it('should trigger a custom (non-native) event', () => {
const handler = vi.fn()
EventHandler.on(div, 'show.bs.modal', handler)
EventHandler.trigger(div, 'show.bs.modal')
expect(handler).toHaveBeenCalledOnce()
EventHandler.off(div, 'show.bs.modal')
})
it('should allow preventing the default action', () => {
EventHandler.on(div, 'show.bs.test', (event: unknown) => {
(event as Event).preventDefault()
})
const evt = EventHandler.trigger(div, 'show.bs.test')
expect(evt!.defaultPrevented).toBe(true)
EventHandler.off(div, 'show.bs.test')
})
})
describe('namespaces', () => {
it('should support namespaced events', () => {
const handler = vi.fn()
EventHandler.on(div, 'click.bs.test', handler)
div.click()
expect(handler).toHaveBeenCalledOnce()
EventHandler.off(div, 'click.bs.test')
})
it('should remove only namespaced handlers with off', () => {
const handler1 = vi.fn()
const handler2 = vi.fn()
EventHandler.on(div, 'click.ns1', handler1)
EventHandler.on(div, 'click.ns2', handler2)
EventHandler.off(div, 'click.ns1', handler1)
div.click()
expect(handler1).not.toHaveBeenCalled()
expect(handler2).toHaveBeenCalledOnce()
EventHandler.off(div, 'click.ns2')
})
it('should remove all handlers for a namespace with dot prefix', () => {
const clickHandler = vi.fn()
const focusHandler = vi.fn()
EventHandler.on(div, 'click.bs.test', clickHandler)
EventHandler.on(div, 'focusin.bs.test', focusHandler)
EventHandler.off(div, '.bs.test')
div.click()
div.dispatchEvent(new Event('focusin'))
expect(clickHandler).not.toHaveBeenCalled()
expect(focusHandler).not.toHaveBeenCalled()
})
})
describe('delegation', () => {
it('should handle delegated events', () => {
const handler = vi.fn()
const btn = fixtureEl.querySelector('button')!
EventHandler.on(div, 'click', 'button', handler)
btn.click()
expect(handler).toHaveBeenCalledOnce()
EventHandler.off(div, 'click')
})
it('should not fire for non-matching delegated elements', () => {
const handler = vi.fn()
const span = fixtureEl.querySelector('span')!
EventHandler.on(div, 'click', 'button', handler)
span.click()
expect(handler).not.toHaveBeenCalled()
EventHandler.off(div, 'click')
})
it('should set delegateTarget on the event', () => {
let delegateTarget: EventTarget | null = null
const btn = fixtureEl.querySelector('button')!
EventHandler.on(div, 'click', 'button', (event: unknown) => {
delegateTarget = (event as Record<string, unknown>).delegateTarget as EventTarget
})
btn.click()
expect(delegateTarget).toBe(btn)
EventHandler.off(div, 'click')
})
it('should remove delegated handler with off', () => {
const handler = vi.fn()
const btn = fixtureEl.querySelector('button')!
EventHandler.on(div, 'click', 'button', handler)
EventHandler.off(div, 'click', 'button', handler)
btn.click()
expect(handler).not.toHaveBeenCalled()
})
it('should support one-off delegated events', () => {
const handler = vi.fn()
const btn = fixtureEl.querySelector('button')!
EventHandler.one(div, 'click', 'button', handler)
btn.click()
btn.click()
expect(handler).toHaveBeenCalledOnce()
})
})
describe('mouseenter / mouseleave (custom events)', () => {
it('should fire mouseenter handler when relatedTarget is outside', () => {
const handler = vi.fn()
EventHandler.on(div, 'mouseenter', handler)
const mouseoverEvent = new MouseEvent('mouseover', {
bubbles: true,
relatedTarget: document.body
})
div.dispatchEvent(mouseoverEvent)
expect(handler).toHaveBeenCalledOnce()
EventHandler.off(div, 'mouseenter')
})
it('should not fire mouseenter handler when relatedTarget is inside delegateTarget', () => {
const handler = vi.fn()
const btn = fixtureEl.querySelector('button')!
EventHandler.on(div, 'mouseenter', handler)
const mouseoverEvent = new MouseEvent('mouseover', {
bubbles: true,
relatedTarget: btn
})
div.dispatchEvent(mouseoverEvent)
expect(handler).not.toHaveBeenCalled()
EventHandler.off(div, 'mouseenter')
})
it('should fire mouseenter handler when relatedTarget is null', () => {
const handler = vi.fn()
EventHandler.on(div, 'mouseenter', handler)
const mouseoverEvent = new MouseEvent('mouseover', {
bubbles: true,
relatedTarget: null
})
div.dispatchEvent(mouseoverEvent)
expect(handler).toHaveBeenCalledOnce()
EventHandler.off(div, 'mouseenter')
})
})
describe('removeHandler guard', () => {
it('should not throw when removing a handler that was never added', () => {
const handler = vi.fn()
const otherHandler = vi.fn()
EventHandler.on(div, 'click', handler)
expect(() => {
EventHandler.off(div, 'click', otherHandler)
}).not.toThrow()
div.click()
expect(handler).toHaveBeenCalledOnce()
EventHandler.off(div, 'click')
})
})
describe('trigger extras', () => {
it('should attach custom properties to the triggered event', () => {
let receivedEvent: Event | null = null
EventHandler.on(div, 'show.bs.test', (event: unknown) => {
receivedEvent = event as Event
})
EventHandler.trigger(div, 'show.bs.test', { customProp: 42, anotherProp: 'hello' })
expect(receivedEvent).not.toBeNull()
const evt = receivedEvent as unknown as Record<string, unknown>
expect(evt.customProp).toBe(42)
expect(evt.anotherProp).toBe('hello')
EventHandler.off(div, 'show.bs.test')
})
it('should work when trigger has no extra args', () => {
const handler = vi.fn()
EventHandler.on(div, 'show.bs.test', handler)
const evt = EventHandler.trigger(div, 'show.bs.test')
expect(evt).toBeInstanceOf(Event)
expect(handler).toHaveBeenCalledOnce()
EventHandler.off(div, 'show.bs.test')
})
it('should use Object.defineProperty for read-only properties', () => {
let receivedEvent: Event | null = null
EventHandler.on(div, 'click', (event: unknown) => {
receivedEvent = event as Event
})
EventHandler.trigger(div, 'click', { type: 'overridden' })
expect(receivedEvent).not.toBeNull()
expect((receivedEvent as unknown as Record<string, unknown>).type).toBe('overridden')
EventHandler.off(div, 'click')
})
})
})

View File

@@ -0,0 +1,201 @@
import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest'
import Manipulator from '../../../src/bootstrap/dom/manipulator'
import { clearFixture, getFixture } from '../../helpers/fixture'
describe('Manipulator', () => {
let fixtureEl: HTMLElement
let div: HTMLElement
beforeAll(() => {
fixtureEl = getFixture()
})
beforeEach(() => {
fixtureEl.innerHTML = '<div></div>'
div = fixtureEl.querySelector('div')!
})
afterEach(() => {
clearFixture()
})
describe('setDataAttribute', () => {
it('should set a data-tblr-* attribute', () => {
Manipulator.setDataAttribute(div, 'key', 'value')
expect(div.getAttribute('data-tblr-key')).toBe('value')
})
it('should convert camelCase keys to kebab-case', () => {
Manipulator.setDataAttribute(div, 'testKey', '123')
expect(div.getAttribute('data-tblr-test-key')).toBe('123')
})
it('should overwrite existing value', () => {
Manipulator.setDataAttribute(div, 'key', 'old')
Manipulator.setDataAttribute(div, 'key', 'new')
expect(div.getAttribute('data-tblr-key')).toBe('new')
})
})
describe('removeDataAttribute', () => {
it('should remove data-tblr-* attribute', () => {
div.setAttribute('data-tblr-key', 'value')
Manipulator.removeDataAttribute(div, 'key')
expect(div.getAttribute('data-tblr-key')).toBeNull()
})
it('should remove data-bs-* attribute', () => {
div.setAttribute('data-bs-key', 'value')
Manipulator.removeDataAttribute(div, 'key')
expect(div.getAttribute('data-bs-key')).toBeNull()
})
it('should remove both prefixes at once', () => {
div.setAttribute('data-tblr-key', 'a')
div.setAttribute('data-bs-key', 'b')
Manipulator.removeDataAttribute(div, 'key')
expect(div.getAttribute('data-tblr-key')).toBeNull()
expect(div.getAttribute('data-bs-key')).toBeNull()
})
it('should handle camelCase keys', () => {
div.setAttribute('data-tblr-some-thing', 'x')
Manipulator.removeDataAttribute(div, 'someThing')
expect(div.getAttribute('data-tblr-some-thing')).toBeNull()
})
})
describe('getDataAttribute', () => {
it('should prioritize data-tblr-* over data-bs-*', () => {
div.setAttribute('data-tblr-key', 'tblr-value')
div.setAttribute('data-bs-key', 'bs-value')
expect(Manipulator.getDataAttribute(div, 'key')).toBe('tblr-value')
})
it('should fall back to data-bs-* if data-tblr-* is absent', () => {
div.setAttribute('data-bs-key', 'bs-value')
expect(Manipulator.getDataAttribute(div, 'key')).toBe('bs-value')
})
it('should return null if neither prefix exists', () => {
expect(Manipulator.getDataAttribute(div, 'missing')).toBeNull()
})
it('should normalize "true" to boolean true', () => {
div.setAttribute('data-tblr-flag', 'true')
expect(Manipulator.getDataAttribute(div, 'flag')).toBe(true)
})
it('should normalize "false" to boolean false', () => {
div.setAttribute('data-tblr-flag', 'false')
expect(Manipulator.getDataAttribute(div, 'flag')).toBe(false)
})
it('should normalize numeric strings to numbers', () => {
div.setAttribute('data-tblr-count', '42')
expect(Manipulator.getDataAttribute(div, 'count')).toBe(42)
})
it('should normalize "null" to null', () => {
div.setAttribute('data-tblr-val', 'null')
expect(Manipulator.getDataAttribute(div, 'val')).toBeNull()
})
it('should normalize empty string to null', () => {
div.setAttribute('data-tblr-val', '')
expect(Manipulator.getDataAttribute(div, 'val')).toBeNull()
})
it('should parse JSON-encoded values', () => {
div.setAttribute('data-tblr-obj', '{"a":1}')
expect(Manipulator.getDataAttribute(div, 'obj')).toEqual({ a: 1 })
})
it('should return raw string for non-parseable values', () => {
div.setAttribute('data-tblr-val', 'hello world')
expect(Manipulator.getDataAttribute(div, 'val')).toBe('hello world')
})
it('should handle camelCase key lookup', () => {
div.setAttribute('data-tblr-my-key', 'yes')
expect(Manipulator.getDataAttribute(div, 'myKey')).toBe('yes')
})
})
describe('getDataAttributes', () => {
it('should return empty object for null element', () => {
expect(Manipulator.getDataAttributes(null)).toEqual({})
})
it('should return empty object when no data attributes exist', () => {
expect(Manipulator.getDataAttributes(div)).toEqual({})
})
it('should collect data-tblr-* attributes', () => {
div.setAttribute('data-tblr-name', 'test')
div.setAttribute('data-tblr-count', '5')
const attrs = Manipulator.getDataAttributes(div)
expect(attrs.name).toBe('test')
expect(attrs.count).toBe(5)
})
it('should collect data-bs-* attributes', () => {
div.setAttribute('data-bs-toggle', 'modal')
const attrs = Manipulator.getDataAttributes(div)
expect(attrs.toggle).toBe('modal')
})
it('should prioritize tblr over bs for the same key', () => {
div.setAttribute('data-tblr-key', 'tblr')
div.setAttribute('data-bs-key', 'bs')
const attrs = Manipulator.getDataAttributes(div)
expect(attrs.key).toBe('tblr')
})
it('should exclude *Config attributes', () => {
div.setAttribute('data-tblr-config', '{}')
div.setAttribute('data-bs-config', '{}')
div.setAttribute('data-tblr-name', 'hello')
const attrs = Manipulator.getDataAttributes(div)
expect(attrs.name).toBe('hello')
expect('config' in attrs).toBe(false)
})
it('should normalize all values', () => {
div.setAttribute('data-tblr-flag', 'true')
div.setAttribute('data-tblr-num', '10')
div.setAttribute('data-tblr-nil', 'null')
const attrs = Manipulator.getDataAttributes(div)
expect(attrs.flag).toBe(true)
expect(attrs.num).toBe(10)
expect(attrs.nil).toBeNull()
})
})
})

View File

@@ -0,0 +1,300 @@
import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest'
import SelectorEngine from '../../../src/bootstrap/dom/selector-engine'
import { clearFixture, getFixture } from '../../helpers/fixture'
vi.mock('../../../src/bootstrap/util/index', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../src/bootstrap/util/index')>()
return {
...actual,
isVisible: () => true
}
})
describe('SelectorEngine', () => {
let fixtureEl: HTMLElement
beforeAll(() => {
fixtureEl = getFixture()
})
afterEach(() => {
clearFixture()
})
describe('find', () => {
beforeEach(() => {
fixtureEl.innerHTML = '<div><span class="a"></span><span class="b"></span></div>'
})
it('should return an array of matched elements', () => {
const result = SelectorEngine.find('span', fixtureEl)
expect(result).toHaveLength(2)
expect(result[0].classList.contains('a')).toBe(true)
expect(result[1].classList.contains('b')).toBe(true)
})
it('should return an empty array when nothing matches', () => {
expect(SelectorEngine.find('.missing', fixtureEl)).toEqual([])
})
it('should default to document.documentElement', () => {
const result = SelectorEngine.find(`#${fixtureEl.id} span`)
expect(result).toHaveLength(2)
})
})
describe('findOne', () => {
beforeEach(() => {
fixtureEl.innerHTML = '<div><span class="first"></span><span class="second"></span></div>'
})
it('should return the first matched element', () => {
const result = SelectorEngine.findOne('span', fixtureEl)
expect(result).not.toBeNull()
expect(result!.classList.contains('first')).toBe(true)
})
it('should return null when nothing matches', () => {
expect(SelectorEngine.findOne('.missing', fixtureEl)).toBeNull()
})
})
describe('children', () => {
beforeEach(() => {
fixtureEl.innerHTML = '<div><span class="match"></span><p></p><span class="match"></span></div>'
})
it('should return only direct children matching the selector', () => {
const parent = fixtureEl.querySelector('div')!
const result = SelectorEngine.children(parent, 'span')
expect(result).toHaveLength(2)
result.forEach(el => expect(el.tagName).toBe('SPAN'))
})
it('should return an empty array when no children match', () => {
const parent = fixtureEl.querySelector('div')!
expect(SelectorEngine.children(parent, 'button')).toEqual([])
})
})
describe('parents', () => {
beforeEach(() => {
fixtureEl.innerHTML = '<div class="outer"><div class="inner"><span id="target"></span></div></div>'
})
it('should return all ancestor elements matching the selector', () => {
const target = fixtureEl.querySelector('#target')!
const result = SelectorEngine.parents(target as HTMLElement, 'div')
expect(result.length).toBeGreaterThanOrEqual(2)
expect(result[0].classList.contains('inner')).toBe(true)
expect(result[1].classList.contains('outer')).toBe(true)
})
it('should return an empty array when no ancestors match', () => {
const target = fixtureEl.querySelector('#target')!
expect(SelectorEngine.parents(target as HTMLElement, 'table')).toEqual([])
})
})
describe('prev', () => {
beforeEach(() => {
fixtureEl.innerHTML = '<div><span class="a"></span><p class="b"></p><span class="c"></span></div>'
})
it('should return the previous sibling matching the selector', () => {
const el = fixtureEl.querySelector('.c')! as HTMLElement
const result = SelectorEngine.prev(el, 'span')
expect(result).toHaveLength(1)
expect(result[0].classList.contains('a')).toBe(true)
})
it('should return an empty array if no previous sibling matches', () => {
const el = fixtureEl.querySelector('.a')! as HTMLElement
expect(SelectorEngine.prev(el, 'button')).toEqual([])
})
it('should skip non-matching siblings', () => {
const el = fixtureEl.querySelector('.c')! as HTMLElement
const result = SelectorEngine.prev(el, 'p')
expect(result).toHaveLength(1)
expect(result[0].classList.contains('b')).toBe(true)
})
})
describe('next', () => {
beforeEach(() => {
fixtureEl.innerHTML = '<div><span class="a"></span><p class="b"></p><span class="c"></span></div>'
})
it('should return the next sibling matching the selector', () => {
const el = fixtureEl.querySelector('.a')! as HTMLElement
const result = SelectorEngine.next(el, 'span')
expect(result).toHaveLength(1)
expect(result[0].classList.contains('c')).toBe(true)
})
it('should return an empty array if no next sibling matches', () => {
const el = fixtureEl.querySelector('.c')! as HTMLElement
expect(SelectorEngine.next(el, 'button')).toEqual([])
})
it('should skip non-matching siblings', () => {
const el = fixtureEl.querySelector('.a')! as HTMLElement
const result = SelectorEngine.next(el, 'p')
expect(result).toHaveLength(1)
expect(result[0].classList.contains('b')).toBe(true)
})
})
describe('focusableChildren', () => {
it('should return focusable children', () => {
fixtureEl.innerHTML = '<div><button>OK</button><input type="text"><span>text</span></div>'
const parent = fixtureEl.querySelector('div')!
const result = SelectorEngine.focusableChildren(parent)
expect(result.length).toBeGreaterThanOrEqual(1)
const tags = result.map(el => el.tagName)
expect(tags).toContain('BUTTON')
expect(tags).toContain('INPUT')
expect(tags).not.toContain('SPAN')
})
it('should exclude disabled elements', () => {
fixtureEl.innerHTML = '<div><button disabled>No</button><button>Yes</button></div>'
const parent = fixtureEl.querySelector('div')!
const result = SelectorEngine.focusableChildren(parent)
const texts = result.map(el => el.textContent)
expect(texts).toContain('Yes')
expect(texts).not.toContain('No')
})
it('should exclude elements with negative tabindex', () => {
fixtureEl.innerHTML = '<div><button tabindex="-1">Hidden</button><button>Visible</button></div>'
const parent = fixtureEl.querySelector('div')!
const result = SelectorEngine.focusableChildren(parent)
const texts = result.map(el => el.textContent)
expect(texts).toContain('Visible')
expect(texts).not.toContain('Hidden')
})
})
describe('getSelector / getSelectorFromElement / getElementFromSelector', () => {
it('should resolve data-tblr-target', () => {
fixtureEl.innerHTML = '<div id="target"></div><a data-tblr-target="#target"></a>'
const trigger = fixtureEl.querySelector('a')! as HTMLElement
expect(SelectorEngine.getElementFromSelector(trigger)).toBe(fixtureEl.querySelector('#target'))
})
it('should resolve data-bs-target as fallback', () => {
fixtureEl.innerHTML = '<div id="target"></div><a data-bs-target="#target"></a>'
const trigger = fixtureEl.querySelector('a')! as HTMLElement
expect(SelectorEngine.getElementFromSelector(trigger)).toBe(fixtureEl.querySelector('#target'))
})
it('should prioritize data-tblr-target over data-bs-target', () => {
fixtureEl.innerHTML = '<div id="tblr"></div><div id="bs"></div><a data-tblr-target="#tblr" data-bs-target="#bs"></a>'
const trigger = fixtureEl.querySelector('a')! as HTMLElement
expect(SelectorEngine.getElementFromSelector(trigger)!.id).toBe('tblr')
})
it('should resolve href as fallback', () => {
fixtureEl.innerHTML = '<div id="target"></div><a href="#target"></a>'
const trigger = fixtureEl.querySelector('a')! as HTMLElement
expect(SelectorEngine.getElementFromSelector(trigger)).toBe(fixtureEl.querySelector('#target'))
})
it('should return null when no selector can be resolved', () => {
fixtureEl.innerHTML = '<a></a>'
const trigger = fixtureEl.querySelector('a')! as HTMLElement
expect(SelectorEngine.getElementFromSelector(trigger)).toBeNull()
})
it('should return null for href without hash or dot', () => {
fixtureEl.innerHTML = '<a href="https://example.com"></a>'
const trigger = fixtureEl.querySelector('a')! as HTMLElement
expect(SelectorEngine.getElementFromSelector(trigger)).toBeNull()
})
it('should return null for href="#"', () => {
fixtureEl.innerHTML = '<a href="#"></a>'
const trigger = fixtureEl.querySelector('a')! as HTMLElement
expect(SelectorEngine.getElementFromSelector(trigger)).toBeNull()
})
it('should extract hash from full URL href', () => {
fixtureEl.innerHTML = '<div id="section"></div><a href="http://example.com/page#section"></a>'
const trigger = fixtureEl.querySelector('a')! as HTMLElement
expect(SelectorEngine.getElementFromSelector(trigger)).toBe(fixtureEl.querySelector('#section'))
})
it('should resolve class-based href selectors', () => {
fixtureEl.innerHTML = '<div class="target"></div><a href=".target"></a>'
const trigger = fixtureEl.querySelector('a')! as HTMLElement
expect(SelectorEngine.getElementFromSelector(trigger)).toBe(fixtureEl.querySelector('.target'))
})
it('getSelectorFromElement should return selector string when element exists', () => {
fixtureEl.innerHTML = '<div id="target"></div><a data-tblr-target="#target"></a>'
const trigger = fixtureEl.querySelector('a')! as HTMLElement
expect(SelectorEngine.getSelectorFromElement(trigger)).toBe('#target')
})
it('getSelectorFromElement should return null when target element does not exist', () => {
fixtureEl.innerHTML = '<a data-tblr-target="#nonexistent"></a>'
const trigger = fixtureEl.querySelector('a')! as HTMLElement
expect(SelectorEngine.getSelectorFromElement(trigger)).toBeNull()
})
it('getSelectorFromElement should return null when no selector', () => {
fixtureEl.innerHTML = '<a></a>'
const trigger = fixtureEl.querySelector('a')! as HTMLElement
expect(SelectorEngine.getSelectorFromElement(trigger)).toBeNull()
})
})
describe('getMultipleElementsFromSelector', () => {
it('should return all matching elements', () => {
fixtureEl.innerHTML = '<div class="item"></div><div class="item"></div><a data-tblr-target=".item"></a>'
const trigger = fixtureEl.querySelector('a')! as HTMLElement
expect(SelectorEngine.getMultipleElementsFromSelector(trigger)).toHaveLength(2)
})
it('should return an empty array when no selector', () => {
fixtureEl.innerHTML = '<a></a>'
const trigger = fixtureEl.querySelector('a')! as HTMLElement
expect(SelectorEngine.getMultipleElementsFromSelector(trigger)).toEqual([])
})
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,646 @@
import { describe, it, expect, beforeAll, afterEach, vi } from 'vitest'
import Modal from '../../src/bootstrap/modal'
import { clearFixture, getFixture } from '../helpers/fixture'
describe('Modal', () => {
let fixtureEl: HTMLElement
beforeAll(() => {
fixtureEl = getFixture()
})
afterEach(() => {
clearFixture()
vi.restoreAllMocks()
document.body.classList.remove('modal-open')
for (const el of document.querySelectorAll('.modal-backdrop')) {
el.remove()
}
})
const createModalHTML = () => [
'<div class="modal" tabindex="-1">',
' <div class="modal-dialog">',
' <div class="modal-content">',
' <div class="modal-header">',
' <h5 class="modal-title">Modal</h5>',
' <button type="button" class="btn-close" data-bs-dismiss="modal"></button>',
' </div>',
' <div class="modal-body"><p>Content</p></div>',
' </div>',
' </div>',
'</div>'
].join('')
describe('VERSION', () => {
it('should return plugin version', () => {
expect(typeof Modal.VERSION).toBe('string')
})
})
describe('Default', () => {
it('should return plugin default config', () => {
expect(Modal.Default).toBeDefined()
expect(Modal.Default.backdrop).toBe(true)
expect(Modal.Default.focus).toBe(true)
expect(Modal.Default.keyboard).toBe(true)
})
})
describe('DefaultType', () => {
it('should return plugin default type config', () => {
expect(Modal.DefaultType).toBeDefined()
expect(Modal.DefaultType.backdrop).toBe('(boolean|string)')
})
})
describe('NAME', () => {
it('should return plugin name', () => {
expect(Modal.NAME).toBe('modal')
})
})
describe('constructor', () => {
it('should create modal instance', () => {
fixtureEl.innerHTML = createModalHTML()
const modalEl = fixtureEl.querySelector('.modal')!
const modal = new Modal(modalEl)
expect(modal).toBeInstanceOf(Modal)
expect(Modal.getInstance(modalEl)).toBe(modal)
})
it('should find dialog element', () => {
fixtureEl.innerHTML = createModalHTML()
const modalEl = fixtureEl.querySelector('.modal')!
const modal = new Modal(modalEl)
expect(modal._dialog).not.toBeNull()
})
})
describe('toggle', () => {
it('should show when hidden', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = createModalHTML()
const modalEl = fixtureEl.querySelector('.modal')!
const modal = new Modal(modalEl)
modalEl.addEventListener('shown.bs.modal', () => {
expect(modal._isShown).toBe(true)
resolve()
})
modal.toggle()
})
})
it('should hide when shown', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = createModalHTML()
const modalEl = fixtureEl.querySelector('.modal')!
const modal = new Modal(modalEl)
modalEl.addEventListener('shown.bs.modal', () => {
modalEl.addEventListener('hidden.bs.modal', () => {
expect(modal._isShown).toBe(false)
resolve()
})
modal.toggle()
})
modal.show()
})
})
})
describe('show', () => {
it('should show modal', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = createModalHTML()
const modalEl = fixtureEl.querySelector('.modal')!
const modal = new Modal(modalEl)
modalEl.addEventListener('shown.bs.modal', () => {
expect(modal._isShown).toBe(true)
expect(modalEl.classList.contains('show')).toBe(true)
expect(modalEl.getAttribute('aria-modal')).toBe('true')
expect(modalEl.getAttribute('role')).toBe('dialog')
expect(document.body.classList.contains('modal-open')).toBe(true)
resolve()
})
modal.show()
})
})
it('should not show if already shown', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = createModalHTML()
const modalEl = fixtureEl.querySelector('.modal')!
const modal = new Modal(modalEl)
modalEl.addEventListener('shown.bs.modal', () => {
modal.show()
setTimeout(() => {
expect(modal._isShown).toBe(true)
resolve()
}, 30)
})
modal.show()
})
})
it('should not show if show event is prevented', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = createModalHTML()
const modalEl = fixtureEl.querySelector('.modal')!
const modal = new Modal(modalEl)
modalEl.addEventListener('show.bs.modal', event => {
event.preventDefault()
setTimeout(() => {
expect(modal._isShown).toBe(false)
resolve()
}, 30)
})
modal.show()
})
})
it('should pass relatedTarget in show event', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = createModalHTML() + '<button id="trigger">Open</button>'
const modalEl = fixtureEl.querySelector('.modal')!
const trigger = fixtureEl.querySelector('#trigger') as HTMLElement
const modal = new Modal(modalEl)
modalEl.addEventListener('show.bs.modal', (event: any) => {
expect(event.relatedTarget).toBe(trigger)
resolve()
})
modal.show(trigger)
})
})
})
describe('hide', () => {
it('should hide modal', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = createModalHTML()
const modalEl = fixtureEl.querySelector('.modal')!
const modal = new Modal(modalEl)
modalEl.addEventListener('shown.bs.modal', () => {
modal.hide()
})
modalEl.addEventListener('hidden.bs.modal', () => {
expect(modal._isShown).toBe(false)
expect(modalEl.style.display).toBe('none')
expect(modalEl.getAttribute('aria-hidden')).toBe('true')
resolve()
})
modal.show()
})
})
it('should not hide if not shown', () => {
fixtureEl.innerHTML = createModalHTML()
const modalEl = fixtureEl.querySelector('.modal')!
const modal = new Modal(modalEl)
modal.hide()
expect(modal._isShown).toBe(false)
})
it('should not hide if hide event is prevented', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = createModalHTML()
const modalEl = fixtureEl.querySelector('.modal')!
const modal = new Modal(modalEl)
modalEl.addEventListener('shown.bs.modal', () => {
modalEl.addEventListener('hide.bs.modal', event => {
event.preventDefault()
setTimeout(() => {
expect(modal._isShown).toBe(true)
resolve()
}, 30)
})
modal.hide()
})
modal.show()
})
})
})
describe('dispose', () => {
it('should dispose modal', () => {
fixtureEl.innerHTML = createModalHTML()
const modalEl = fixtureEl.querySelector('.modal')!
const modal = new Modal(modalEl)
modal.dispose()
expect(Modal.getInstance(modalEl)).toBeNull()
})
})
describe('handleUpdate', () => {
it('should call _adjustDialog', () => {
fixtureEl.innerHTML = createModalHTML()
const modalEl = fixtureEl.querySelector('.modal')!
const modal = new Modal(modalEl)
const spy = vi.spyOn(modal, '_adjustDialog')
modal.handleUpdate()
expect(spy).toHaveBeenCalled()
})
})
describe('_isAnimated', () => {
it('should return true when fade class is present', () => {
fixtureEl.innerHTML = '<div class="modal fade"><div class="modal-dialog"></div></div>'
const modalEl = fixtureEl.querySelector('.modal')!
const modal = new Modal(modalEl)
expect(modal._isAnimated()).toBe(true)
})
it('should return false without fade class', () => {
fixtureEl.innerHTML = createModalHTML()
const modalEl = fixtureEl.querySelector('.modal')!
const modal = new Modal(modalEl)
expect(modal._isAnimated()).toBe(false)
})
})
describe('keyboard', () => {
it('should close on Escape when keyboard is true', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = createModalHTML()
const modalEl = fixtureEl.querySelector('.modal')!
const modal = new Modal(modalEl, { keyboard: true })
modalEl.addEventListener('shown.bs.modal', () => {
modalEl.addEventListener('hidden.bs.modal', () => {
expect(modal._isShown).toBe(false)
resolve()
})
modalEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
})
modal.show()
})
})
it('should not close on Escape when keyboard is false', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = createModalHTML()
const modalEl = fixtureEl.querySelector('.modal')!
const modal = new Modal(modalEl, { keyboard: false })
modalEl.addEventListener('shown.bs.modal', () => {
modalEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
setTimeout(() => {
expect(modal._isShown).toBe(true)
resolve()
}, 30)
})
modal.show()
})
})
it('should ignore non-Escape keys', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = createModalHTML()
const modalEl = fixtureEl.querySelector('.modal')!
const modal = new Modal(modalEl)
modalEl.addEventListener('shown.bs.modal', () => {
modalEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
setTimeout(() => {
expect(modal._isShown).toBe(true)
resolve()
}, 30)
})
modal.show()
})
})
})
describe('getInstance', () => {
it('should return null if no instance', () => {
expect(Modal.getInstance(fixtureEl)).toBeNull()
})
it('should return modal instance', () => {
fixtureEl.innerHTML = createModalHTML()
const modalEl = fixtureEl.querySelector('.modal')!
const modal = new Modal(modalEl)
expect(Modal.getInstance(modalEl)).toBe(modal)
})
})
describe('getOrCreateInstance', () => {
it('should return existing instance', () => {
fixtureEl.innerHTML = createModalHTML()
const modalEl = fixtureEl.querySelector('.modal')!
const modal = new Modal(modalEl)
expect(Modal.getOrCreateInstance(modalEl)).toBe(modal)
})
it('should create new instance', () => {
fixtureEl.innerHTML = createModalHTML()
const modalEl = fixtureEl.querySelector('.modal')!
expect(Modal.getInstance(modalEl)).toBeNull()
expect(Modal.getOrCreateInstance(modalEl)).toBeInstanceOf(Modal)
})
})
describe('_triggerBackdropTransition', () => {
it('should add and remove modal-static class', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = createModalHTML()
const modalEl = fixtureEl.querySelector('.modal')!
const modal = new Modal(modalEl, { keyboard: false })
modalEl.addEventListener('shown.bs.modal', () => {
modalEl.addEventListener('hidePrevented.bs.modal', () => {
setTimeout(() => {
resolve()
}, 30)
})
modalEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
})
modal.show()
})
})
it('should not transition if hidePrevented is prevented', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = createModalHTML()
const modalEl = fixtureEl.querySelector('.modal')!
const modal = new Modal(modalEl, { keyboard: false })
modalEl.addEventListener('shown.bs.modal', () => {
modalEl.addEventListener('hidePrevented.bs.modal', event => {
event.preventDefault()
setTimeout(() => {
expect(modal._isShown).toBe(true)
resolve()
}, 30)
})
modalEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
})
modal.show()
})
})
it('should early return if overflowY is hidden', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = createModalHTML()
const modalEl = fixtureEl.querySelector('.modal')!
const modal = new Modal(modalEl, { keyboard: false })
modalEl.addEventListener('shown.bs.modal', () => {
modalEl.style.overflowY = 'hidden'
modalEl.addEventListener('hidePrevented.bs.modal', () => {
setTimeout(() => {
expect(modal._isShown).toBe(true)
resolve()
}, 30)
})
modalEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
})
modal.show()
})
})
it('should early return if already has modal-static class', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = createModalHTML()
const modalEl = fixtureEl.querySelector('.modal')!
const modal = new Modal(modalEl, { keyboard: false })
modalEl.addEventListener('shown.bs.modal', () => {
modalEl.classList.add('modal-static')
modalEl.addEventListener('hidePrevented.bs.modal', () => {
setTimeout(() => {
expect(modal._isShown).toBe(true)
resolve()
}, 30)
})
modalEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
})
modal.show()
})
})
})
describe('backdrop click', () => {
it('should hide when clicking outside dialog with backdrop true', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = createModalHTML()
const modalEl = fixtureEl.querySelector('.modal')!
const modal = new Modal(modalEl, { backdrop: true })
modalEl.addEventListener('shown.bs.modal', () => {
modalEl.addEventListener('hidden.bs.modal', () => {
expect(modal._isShown).toBe(false)
resolve()
})
modalEl.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
modalEl.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
modal.show()
})
})
it('should not hide when click starts inside dialog', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = createModalHTML()
const modalEl = fixtureEl.querySelector('.modal')!
const dialog = modalEl.querySelector('.modal-dialog')!
const modal = new Modal(modalEl)
modalEl.addEventListener('shown.bs.modal', () => {
const mousedownEvent = new MouseEvent('mousedown', { bubbles: true })
Object.defineProperty(mousedownEvent, 'target', { value: dialog })
modalEl.dispatchEvent(mousedownEvent)
modalEl.dispatchEvent(new MouseEvent('click', { bubbles: true }))
setTimeout(() => {
expect(modal._isShown).toBe(true)
resolve()
}, 50)
})
modal.show()
})
})
it('should trigger backdrop transition with static backdrop', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = createModalHTML()
const modalEl = fixtureEl.querySelector('.modal')!
const modal = new Modal(modalEl, { backdrop: 'static' })
modalEl.addEventListener('shown.bs.modal', () => {
modalEl.addEventListener('hidePrevented.bs.modal', () => {
expect(modal._isShown).toBe(true)
resolve()
})
modalEl.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
modalEl.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
modal.show()
})
})
})
describe('_showElement', () => {
it('should append to body if not already in DOM', () => {
const modalEl = document.createElement('div')
modalEl.classList.add('modal')
modalEl.setAttribute('tabindex', '-1')
modalEl.innerHTML = '<div class="modal-dialog"><div class="modal-content"></div></div>'
const modal = new Modal(modalEl)
return new Promise<void>(resolve => {
modalEl.addEventListener('shown.bs.modal', () => {
expect(document.body.contains(modalEl)).toBe(true)
modal.dispose()
modalEl.remove()
resolve()
})
modal.show()
})
})
it('should scroll modal body to top', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = createModalHTML()
const modalEl = fixtureEl.querySelector('.modal')!
const modal = new Modal(modalEl)
modalEl.addEventListener('shown.bs.modal', () => {
expect(modalEl.scrollTop).toBe(0)
resolve()
})
modal.show()
})
})
})
describe('data-tblr-toggle', () => {
it('should open modal via data-tblr-toggle="modal"', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<button data-tblr-toggle="modal" data-bs-target="#testModal">Open</button>',
'<div class="modal" id="testModal" tabindex="-1">',
' <div class="modal-dialog"><div class="modal-content"></div></div>',
'</div>'
].join('')
const modalEl = fixtureEl.querySelector('#testModal')!
const btn = fixtureEl.querySelector('[data-tblr-toggle="modal"]') as HTMLElement
modalEl.addEventListener('shown.bs.modal', () => {
const modal = Modal.getInstance(modalEl) as Modal
expect(modal._isShown).toBe(true)
resolve()
})
btn.click()
})
})
it('should open modal via data-tblr-toggle with data-tblr-target', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<button data-tblr-toggle="modal" data-tblr-target="#testModal">Open</button>',
'<div class="modal" id="testModal" tabindex="-1">',
' <div class="modal-dialog"><div class="modal-content"></div></div>',
'</div>'
].join('')
const modalEl = fixtureEl.querySelector('#testModal')!
const btn = fixtureEl.querySelector('[data-tblr-toggle="modal"]') as HTMLElement
modalEl.addEventListener('shown.bs.modal', () => {
const modal = Modal.getInstance(modalEl) as Modal
expect(modal._isShown).toBe(true)
resolve()
})
btn.click()
})
})
})
describe('data-tblr-dismiss', () => {
it('should close modal via data-tblr-dismiss="modal"', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<div class="modal" tabindex="-1">',
' <div class="modal-dialog">',
' <div class="modal-content">',
' <button type="button" class="btn-close" data-tblr-dismiss="modal"></button>',
' </div>',
' </div>',
'</div>'
].join('')
const modalEl = fixtureEl.querySelector('.modal')!
const dismissBtn = fixtureEl.querySelector('[data-tblr-dismiss="modal"]') as HTMLElement
const modal = new Modal(modalEl)
modalEl.addEventListener('shown.bs.modal', () => {
dismissBtn.click()
})
modalEl.addEventListener('hidden.bs.modal', () => {
expect(modal._isShown).toBe(false)
resolve()
})
modal.show()
})
})
})
})

View File

@@ -0,0 +1,458 @@
import { describe, it, expect, beforeAll, afterEach, vi } from 'vitest'
import Offcanvas from '../../src/bootstrap/offcanvas'
import { clearFixture, getFixture } from '../helpers/fixture'
describe('Offcanvas', () => {
let fixtureEl: HTMLElement
beforeAll(() => {
fixtureEl = getFixture()
})
afterEach(() => {
clearFixture()
vi.restoreAllMocks()
for (const el of document.querySelectorAll('.offcanvas-backdrop')) {
el.remove()
}
})
const createOffcanvasHTML = () => [
'<div class="offcanvas offcanvas-start" tabindex="-1">',
' <div class="offcanvas-header">',
' <h5 class="offcanvas-title">Offcanvas</h5>',
' <button type="button" class="btn-close" data-bs-dismiss="offcanvas"></button>',
' </div>',
' <div class="offcanvas-body">Content</div>',
'</div>'
].join('')
describe('VERSION', () => {
it('should return plugin version', () => {
expect(typeof Offcanvas.VERSION).toBe('string')
})
})
describe('Default', () => {
it('should return plugin default config', () => {
expect(Offcanvas.Default).toBeDefined()
expect(Offcanvas.Default.backdrop).toBe(true)
expect(Offcanvas.Default.keyboard).toBe(true)
expect(Offcanvas.Default.scroll).toBe(false)
})
})
describe('DefaultType', () => {
it('should return plugin default type config', () => {
expect(Offcanvas.DefaultType).toBeDefined()
expect(Offcanvas.DefaultType.backdrop).toBe('(boolean|string)')
})
})
describe('NAME', () => {
it('should return plugin name', () => {
expect(Offcanvas.NAME).toBe('offcanvas')
})
})
describe('constructor', () => {
it('should create offcanvas instance', () => {
fixtureEl.innerHTML = createOffcanvasHTML()
const el = fixtureEl.querySelector('.offcanvas')!
const instance = new Offcanvas(el)
expect(instance).toBeInstanceOf(Offcanvas)
expect(Offcanvas.getInstance(el)).toBe(instance)
})
})
describe('toggle', () => {
it('should show when hidden', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = createOffcanvasHTML()
const el = fixtureEl.querySelector('.offcanvas')!
const instance = new Offcanvas(el)
el.addEventListener('shown.bs.offcanvas', () => {
expect(instance._isShown).toBe(true)
resolve()
})
instance.toggle()
})
})
it('should hide when shown', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = createOffcanvasHTML()
const el = fixtureEl.querySelector('.offcanvas')!
const instance = new Offcanvas(el)
el.addEventListener('shown.bs.offcanvas', () => {
el.addEventListener('hidden.bs.offcanvas', () => {
expect(instance._isShown).toBe(false)
resolve()
})
instance.toggle()
})
instance.show()
})
})
})
describe('show', () => {
it('should show offcanvas', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = createOffcanvasHTML()
const el = fixtureEl.querySelector('.offcanvas')!
const instance = new Offcanvas(el)
el.addEventListener('shown.bs.offcanvas', () => {
expect(instance._isShown).toBe(true)
expect(el.classList.contains('show')).toBe(true)
expect(el.getAttribute('aria-modal')).toBe('true')
expect(el.getAttribute('role')).toBe('dialog')
resolve()
})
instance.show()
})
})
it('should not show if already shown', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = createOffcanvasHTML()
const el = fixtureEl.querySelector('.offcanvas')!
const instance = new Offcanvas(el)
let showCount = 0
el.addEventListener('show.bs.offcanvas', () => {
showCount++
})
el.addEventListener('shown.bs.offcanvas', () => {
instance.show()
setTimeout(() => {
expect(showCount).toBe(1)
resolve()
}, 30)
})
instance.show()
})
})
it('should not show if show event is prevented', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = createOffcanvasHTML()
const el = fixtureEl.querySelector('.offcanvas')!
const instance = new Offcanvas(el)
el.addEventListener('show.bs.offcanvas', event => {
event.preventDefault()
setTimeout(() => {
expect(instance._isShown).toBe(false)
resolve()
}, 30)
})
instance.show()
})
})
it('should pass relatedTarget in show event', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = createOffcanvasHTML() + '<button id="trigger">Open</button>'
const el = fixtureEl.querySelector('.offcanvas')!
const trigger = fixtureEl.querySelector('#trigger') as HTMLElement
const instance = new Offcanvas(el)
el.addEventListener('show.bs.offcanvas', (event: any) => {
expect(event.relatedTarget).toBe(trigger)
resolve()
})
instance.show(trigger)
})
})
})
describe('hide', () => {
it('should hide offcanvas', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = createOffcanvasHTML()
const el = fixtureEl.querySelector('.offcanvas')!
const instance = new Offcanvas(el)
el.addEventListener('shown.bs.offcanvas', () => {
instance.hide()
})
el.addEventListener('hidden.bs.offcanvas', () => {
expect(instance._isShown).toBe(false)
expect(el.classList.contains('show')).toBe(false)
resolve()
})
instance.show()
})
})
it('should not hide if not shown', () => {
fixtureEl.innerHTML = createOffcanvasHTML()
const el = fixtureEl.querySelector('.offcanvas')!
const instance = new Offcanvas(el)
let hideCount = 0
el.addEventListener('hide.bs.offcanvas', () => {
hideCount++
})
instance.hide()
expect(hideCount).toBe(0)
})
it('should not hide if hide event is prevented', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = createOffcanvasHTML()
const el = fixtureEl.querySelector('.offcanvas')!
const instance = new Offcanvas(el)
el.addEventListener('shown.bs.offcanvas', () => {
el.addEventListener('hide.bs.offcanvas', event => {
event.preventDefault()
setTimeout(() => {
expect(instance._isShown).toBe(true)
resolve()
}, 30)
})
instance.hide()
})
instance.show()
})
})
})
describe('dispose', () => {
it('should dispose offcanvas', () => {
fixtureEl.innerHTML = createOffcanvasHTML()
const el = fixtureEl.querySelector('.offcanvas')!
const instance = new Offcanvas(el)
instance.dispose()
expect(Offcanvas.getInstance(el)).toBeNull()
})
})
describe('keyboard', () => {
it('should close on Escape when keyboard is true', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = createOffcanvasHTML()
const el = fixtureEl.querySelector('.offcanvas')!
const instance = new Offcanvas(el, { keyboard: true })
el.addEventListener('shown.bs.offcanvas', () => {
el.addEventListener('hidden.bs.offcanvas', () => {
expect(instance._isShown).toBe(false)
resolve()
})
el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
})
instance.show()
})
})
it('should fire hidePrevented when keyboard is false and Escape pressed', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = createOffcanvasHTML()
const el = fixtureEl.querySelector('.offcanvas')!
new Offcanvas(el, { keyboard: false })
el.addEventListener('shown.bs.offcanvas', () => {
el.addEventListener('hidePrevented.bs.offcanvas', () => {
resolve()
})
el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
})
Offcanvas.getOrCreateInstance(el)?.show()
})
})
it('should ignore non-Escape keys', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = createOffcanvasHTML()
const el = fixtureEl.querySelector('.offcanvas')!
const instance = new Offcanvas(el)
el.addEventListener('shown.bs.offcanvas', () => {
el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
setTimeout(() => {
expect(instance._isShown).toBe(true)
resolve()
}, 30)
})
instance.show()
})
})
})
describe('getInstance', () => {
it('should return null if no instance', () => {
expect(Offcanvas.getInstance(fixtureEl)).toBeNull()
})
it('should return offcanvas instance', () => {
fixtureEl.innerHTML = createOffcanvasHTML()
const el = fixtureEl.querySelector('.offcanvas')!
const instance = new Offcanvas(el)
expect(Offcanvas.getInstance(el)).toBe(instance)
})
})
describe('getOrCreateInstance', () => {
it('should return existing instance', () => {
fixtureEl.innerHTML = createOffcanvasHTML()
const el = fixtureEl.querySelector('.offcanvas')!
const instance = new Offcanvas(el)
expect(Offcanvas.getOrCreateInstance(el)).toBe(instance)
})
it('should create new instance', () => {
fixtureEl.innerHTML = createOffcanvasHTML()
const el = fixtureEl.querySelector('.offcanvas')!
expect(Offcanvas.getInstance(el)).toBeNull()
expect(Offcanvas.getOrCreateInstance(el)).toBeInstanceOf(Offcanvas)
})
})
describe('scroll option', () => {
it('should not hide scrollbar when scroll is true', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = createOffcanvasHTML()
const el = fixtureEl.querySelector('.offcanvas')!
const instance = new Offcanvas(el, { scroll: true })
el.addEventListener('shown.bs.offcanvas', () => {
expect(instance._isShown).toBe(true)
resolve()
})
instance.show()
})
})
})
describe('backdrop option', () => {
it('should work with backdrop set to false', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = createOffcanvasHTML()
const el = fixtureEl.querySelector('.offcanvas')!
const instance = new Offcanvas(el, { backdrop: false })
el.addEventListener('shown.bs.offcanvas', () => {
expect(instance._isShown).toBe(true)
resolve()
})
instance.show()
})
})
it('should create offcanvas with static backdrop option', () => {
fixtureEl.innerHTML = createOffcanvasHTML()
const el = fixtureEl.querySelector('.offcanvas')!
const instance = new Offcanvas(el, { backdrop: 'static' })
expect(instance).toBeInstanceOf(Offcanvas)
})
})
describe('data-tblr-toggle', () => {
it('should open offcanvas via data-tblr-toggle="offcanvas"', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<button data-tblr-toggle="offcanvas" data-bs-target="#testOffcanvas">Open</button>',
'<div class="offcanvas offcanvas-start" id="testOffcanvas" tabindex="-1">',
' <div class="offcanvas-body">Content</div>',
'</div>'
].join('')
const offcanvasEl = fixtureEl.querySelector('#testOffcanvas')!
const btn = fixtureEl.querySelector('[data-tblr-toggle="offcanvas"]') as HTMLElement
offcanvasEl.addEventListener('shown.bs.offcanvas', () => {
const instance = Offcanvas.getInstance(offcanvasEl) as Offcanvas
expect(instance._isShown).toBe(true)
resolve()
})
btn.click()
})
})
it('should open offcanvas via data-tblr-toggle with data-tblr-target', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<button data-tblr-toggle="offcanvas" data-tblr-target="#testOffcanvas">Open</button>',
'<div class="offcanvas offcanvas-start" id="testOffcanvas" tabindex="-1">',
' <div class="offcanvas-body">Content</div>',
'</div>'
].join('')
const offcanvasEl = fixtureEl.querySelector('#testOffcanvas')!
const btn = fixtureEl.querySelector('[data-tblr-toggle="offcanvas"]') as HTMLElement
offcanvasEl.addEventListener('shown.bs.offcanvas', () => {
const instance = Offcanvas.getInstance(offcanvasEl) as Offcanvas
expect(instance._isShown).toBe(true)
resolve()
})
btn.click()
})
})
})
describe('data-tblr-dismiss', () => {
it('should close offcanvas via data-tblr-dismiss="offcanvas"', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<div class="offcanvas offcanvas-start" tabindex="-1">',
' <div class="offcanvas-header">',
' <button type="button" class="btn-close" data-tblr-dismiss="offcanvas"></button>',
' </div>',
' <div class="offcanvas-body">Content</div>',
'</div>'
].join('')
const el = fixtureEl.querySelector('.offcanvas')!
const dismissBtn = fixtureEl.querySelector('[data-tblr-dismiss="offcanvas"]') as HTMLElement
const instance = new Offcanvas(el)
el.addEventListener('shown.bs.offcanvas', () => {
dismissBtn.click()
})
el.addEventListener('hidden.bs.offcanvas', () => {
expect(instance._isShown).toBe(false)
resolve()
})
instance.show()
})
})
})
})

View File

@@ -0,0 +1,253 @@
import { describe, it, expect, beforeAll, afterEach, vi } from 'vitest'
import Popover from '../../src/bootstrap/popover'
import Tooltip from '../../src/bootstrap/tooltip'
import { clearFixture, getFixture } from '../helpers/fixture'
vi.mock('@popperjs/core', () => ({
createPopper: vi.fn(() => ({
destroy: vi.fn(),
update: vi.fn(),
setOptions: vi.fn()
}))
}))
describe('Popover', () => {
let fixtureEl: HTMLElement
beforeAll(() => {
fixtureEl = getFixture()
})
afterEach(() => {
clearFixture()
vi.restoreAllMocks()
for (const el of document.querySelectorAll('.popover')) {
el.remove()
}
})
describe('VERSION', () => {
it('should return plugin version', () => {
expect(typeof Popover.VERSION).toBe('string')
})
})
describe('Default', () => {
it('should return plugin default config', () => {
expect(Popover.Default).toBeDefined()
expect(Popover.Default.trigger).toBe('click')
expect(Popover.Default.placement).toBe('right')
expect(Popover.Default.content).toBe('')
})
})
describe('DefaultType', () => {
it('should return plugin default type config', () => {
expect(Popover.DefaultType).toBeDefined()
expect(Popover.DefaultType.content).toBe('(null|string|element|function)')
})
})
describe('NAME', () => {
it('should return plugin name', () => {
expect(Popover.NAME).toBe('popover')
})
})
describe('extends Tooltip', () => {
it('should be an instance of Tooltip', () => {
fixtureEl.innerHTML = '<a href="#" title="Popover" data-bs-content="Content">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const popover = new Popover(el)
expect(popover).toBeInstanceOf(Tooltip)
})
})
describe('constructor', () => {
it('should create popover instance', () => {
fixtureEl.innerHTML = '<a href="#" title="Popover title" data-bs-content="Content">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const popover = new Popover(el)
expect(popover).toBeInstanceOf(Popover)
expect(Popover.getInstance(el)).toBe(popover)
})
})
describe('show', () => {
it('should show popover', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = '<a href="#" title="Popover title" data-bs-content="Content">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const popover = new Popover(el, { animation: false })
el.addEventListener('shown.bs.popover', () => {
expect(popover.tip).not.toBeNull()
expect(popover.tip!.classList.contains('show')).toBe(true)
resolve()
})
popover.show()
})
})
it('should show popover with only content', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = '<a href="#">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const popover = new Popover(el, { content: 'Only content', animation: false })
el.addEventListener('shown.bs.popover', () => {
expect(popover._isShown()).toBe(true)
resolve()
})
popover.show()
})
})
it('should show popover with only title', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = '<a href="#" title="Only title">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const popover = new Popover(el, { animation: false })
el.addEventListener('shown.bs.popover', () => {
expect(popover._isShown()).toBe(true)
resolve()
})
popover.show()
})
})
})
describe('hide', () => {
it('should hide popover', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = '<a href="#" title="Popover" data-bs-content="Content">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const popover = new Popover(el, { animation: false })
el.addEventListener('shown.bs.popover', () => {
popover.hide()
})
el.addEventListener('hidden.bs.popover', () => {
expect(popover._isShown()).toBe(false)
resolve()
})
popover.show()
})
})
})
describe('dispose', () => {
it('should dispose popover', () => {
fixtureEl.innerHTML = '<a href="#" title="Popover" data-bs-content="Content">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const popover = new Popover(el)
popover.dispose()
expect(Popover.getInstance(el)).toBeNull()
})
})
describe('_isWithContent', () => {
it('should return true with title and content', () => {
fixtureEl.innerHTML = '<a href="#" title="Title">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const popover = new Popover(el, { content: 'Content' })
expect(popover._isWithContent()).toBe(true)
})
it('should return true with only content', () => {
fixtureEl.innerHTML = '<a href="#">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const popover = new Popover(el, { content: 'Content only' })
expect(popover._isWithContent()).toBe(true)
})
it('should return true with only title', () => {
fixtureEl.innerHTML = '<a href="#" title="Title only">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const popover = new Popover(el)
expect(popover._isWithContent()).toBe(true)
})
it('should return false without title or content', () => {
fixtureEl.innerHTML = '<a href="#">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const popover = new Popover(el)
expect(popover._isWithContent()).toBe(false)
})
})
describe('_getContentForTemplate', () => {
it('should return object with header and body selectors', () => {
fixtureEl.innerHTML = '<a href="#" title="Title">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const popover = new Popover(el, { content: 'Body content' })
const templateContent = popover._getContentForTemplate()
expect(templateContent['.popover-header']).toBeDefined()
expect(templateContent['.popover-body']).toBe('Body content')
})
})
describe('_getContent', () => {
it('should return string content', () => {
fixtureEl.innerHTML = '<a href="#">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const popover = new Popover(el, { content: 'Test content' })
expect(popover._getContent()).toBe('Test content')
})
it('should resolve function content', () => {
fixtureEl.innerHTML = '<a href="#">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const popover = new Popover(el, { content: () => 'Function content' })
expect(popover._getContent()).toBe('Function content')
})
})
describe('getInstance', () => {
it('should return null if no instance', () => {
expect(Popover.getInstance(fixtureEl)).toBeNull()
})
it('should return popover instance', () => {
fixtureEl.innerHTML = '<a href="#" title="Popover" data-bs-content="Content">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const popover = new Popover(el)
expect(Popover.getInstance(el)).toBe(popover)
})
})
describe('getOrCreateInstance', () => {
it('should return existing instance', () => {
fixtureEl.innerHTML = '<a href="#" title="Popover" data-bs-content="Content">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const popover = new Popover(el)
expect(Popover.getOrCreateInstance(el)).toBe(popover)
})
it('should create new instance', () => {
fixtureEl.innerHTML = '<a href="#" title="Popover" data-bs-content="Content">Trigger</a>'
const el = fixtureEl.querySelector('a')!
expect(Popover.getInstance(el)).toBeNull()
expect(Popover.getOrCreateInstance(el)).toBeInstanceOf(Popover)
})
})
})

View File

@@ -0,0 +1,681 @@
import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest'
import ScrollSpy from '../../src/bootstrap/scrollspy'
import { clearFixture, createEvent, getFixture } from '../helpers/fixture'
class MockIntersectionObserver implements IntersectionObserver {
readonly root: Element | Document | null = null
readonly rootMargin: string = ''
readonly thresholds: ReadonlyArray<number> = []
callback: IntersectionObserverCallback
options: IntersectionObserverInit | undefined
constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) {
this.callback = callback
this.options = options
this.root = options?.root as Element | null ?? null
this.rootMargin = options?.rootMargin ?? ''
this.thresholds = Array.isArray(options?.threshold) ? options!.threshold : [options?.threshold ?? 0]
}
observe = vi.fn()
unobserve = vi.fn()
disconnect = vi.fn()
takeRecords = vi.fn().mockReturnValue([])
}
const getDummyFixture = () => [
'<nav id="navBar" class="navbar">',
' <ul class="nav">',
' <li class="nav-item"><a id="li-jsm-1" class="nav-link" href="#div-jsm-1">div 1</a></li>',
' </ul>',
'</nav>',
'<div class="content" data-bs-target="#navBar" style="overflow-y: auto">',
' <div id="div-jsm-1">div 1</div>',
'</div>'
].join('')
describe('ScrollSpy', () => {
let fixtureEl: HTMLElement
beforeAll(() => {
fixtureEl = getFixture()
})
beforeEach(() => {
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver)
})
afterEach(() => {
clearFixture()
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
describe('VERSION', () => {
it('should return plugin version', () => {
expect(typeof ScrollSpy.VERSION).toBe('string')
})
})
describe('Default', () => {
it('should return plugin default config', () => {
expect(typeof ScrollSpy.Default).toBe('object')
})
})
describe('DATA_KEY', () => {
it('should return plugin data key', () => {
expect(ScrollSpy.DATA_KEY).toBe('bs.scrollspy')
})
})
describe('constructor', () => {
it('should accept element as CSS selector or DOM element', () => {
fixtureEl.innerHTML = getDummyFixture()
const sSpyEl = fixtureEl.querySelector('.content')!
const sSpyBySelector = new ScrollSpy('.content')
const sSpyByElement = new ScrollSpy(sSpyEl)
expect(sSpyBySelector._element).toBe(sSpyEl)
expect(sSpyByElement._element).toBe(sSpyEl)
})
it('should set _rootElement to null if overflowY is visible', () => {
fixtureEl.innerHTML = [
'<nav id="navigation" class="navbar">',
' <ul class="navbar-nav">',
' <li class="nav-item"><a class="nav-link" href="#one">One</a></li>',
' </ul>',
'</nav>',
'<div id="content" style="overflow-y: visible;">',
' <div id="one" style="height: 300px;">test</div>',
'</div>'
].join('')
const contentEl = fixtureEl.querySelector('#content')!
const originalGetComputedStyle = window.getComputedStyle
vi.spyOn(window, 'getComputedStyle').mockImplementation((el, pseudoElt?) => {
const result = originalGetComputedStyle(el, pseudoElt ?? undefined)
if (el === contentEl) {
return new Proxy(result, {
get(target, prop) {
if (prop === 'overflowY') return 'visible'
return (target as any)[prop]
}
}) as CSSStyleDeclaration
}
return result
})
const scrollSpy = new ScrollSpy(contentEl, {
target: '#navigation'
})
expect(scrollSpy._rootElement).toBeNull()
})
it('should respect threshold option', () => {
fixtureEl.innerHTML = [
'<ul id="navigation" class="navbar">',
' <a class="nav-link" href="#one">One</a>',
'</ul>',
'<div id="content">',
' <div id="one">test</div>',
'</div>'
].join('')
const scrollSpy = new ScrollSpy('#content', {
target: '#navigation',
threshold: [1]
})
expect(scrollSpy._observer!.thresholds).toEqual([1])
})
it('should parse string threshold from data attribute', () => {
fixtureEl.innerHTML = [
'<ul id="navigation" class="navbar">',
' <a class="nav-link" href="#one">One</a>',
'</ul>',
'<div id="content" data-bs-threshold="0,0.2,1">',
' <div id="one">test</div>',
'</div>'
].join('')
const scrollSpy = new ScrollSpy('#content', {
target: '#navigation'
})
expect(scrollSpy._observer!.thresholds).toEqual([0, 0.2, 1])
})
it('should initialize with empty maps when sections are not visible', () => {
fixtureEl.innerHTML = [
'<nav id="navigation" class="navbar">',
' <ul class="navbar-nav">',
' <li class="nav-item"><a class="nav-link" href="#">One</a></li>',
' <li class="nav-item"><a class="nav-link" href="#two">Two</a></li>',
' </ul>',
'</nav>',
'<div id="content" style="height: 200px; overflow-y: auto;">',
' <div id="two" style="height: 300px;">test</div>',
'</div>'
].join('')
const scrollSpy = new ScrollSpy(fixtureEl.querySelector('#content')!, {
target: '#navigation'
})
// jsdom elements are not "visible" (getClientRects returns empty), so maps are empty
expect(scrollSpy._targetLinks).toBeInstanceOf(Map)
expect(scrollSpy._observableSections).toBeInstanceOf(Map)
})
})
describe('refresh', () => {
it('should disconnect existing observer', () => {
fixtureEl.innerHTML = getDummyFixture()
const el = fixtureEl.querySelector('.content')!
const scrollSpy = new ScrollSpy(el)
const spy = vi.spyOn(scrollSpy._observer!, 'disconnect')
scrollSpy.refresh()
expect(spy).toHaveBeenCalled()
})
})
describe('dispose', () => {
it('should dispose a scrollspy', () => {
fixtureEl.innerHTML = getDummyFixture()
const el = fixtureEl.querySelector('.content')!
const scrollSpy = new ScrollSpy(el)
expect(ScrollSpy.getInstance(el)).not.toBeNull()
scrollSpy.dispose()
expect(ScrollSpy.getInstance(el)).toBeNull()
})
})
describe('getInstance', () => {
it('should return scrollspy instance', () => {
fixtureEl.innerHTML = getDummyFixture()
const div = fixtureEl.querySelector('.content')!
const scrollSpy = new ScrollSpy(div, { target: fixtureEl.querySelector('#navBar')! })
expect(ScrollSpy.getInstance(div)).toBe(scrollSpy)
expect(ScrollSpy.getInstance(div)).toBeInstanceOf(ScrollSpy)
})
it('should return null if no instance', () => {
fixtureEl.innerHTML = getDummyFixture()
const div = fixtureEl.querySelector('.content')!
expect(ScrollSpy.getInstance(div)).toBeNull()
})
})
describe('getOrCreateInstance', () => {
it('should return scrollspy instance', () => {
fixtureEl.innerHTML = getDummyFixture()
const div = fixtureEl.querySelector('.content')!
const scrollSpy = new ScrollSpy(div)
expect(ScrollSpy.getOrCreateInstance(div)).toBe(scrollSpy)
expect(ScrollSpy.getOrCreateInstance(div)).toBeInstanceOf(ScrollSpy)
})
it('should return new instance when there is no scrollspy instance', () => {
fixtureEl.innerHTML = getDummyFixture()
const div = fixtureEl.querySelector('.content')!
expect(ScrollSpy.getInstance(div)).toBeNull()
expect(ScrollSpy.getOrCreateInstance(div)).toBeInstanceOf(ScrollSpy)
})
it('should return new instance with given configuration', () => {
fixtureEl.innerHTML = getDummyFixture()
const div = fixtureEl.querySelector('.content')!
const scrollspy = ScrollSpy.getOrCreateInstance(div, { offset: 1 })
expect(scrollspy).toBeInstanceOf(ScrollSpy)
expect(scrollspy._config.offset).toBe(1)
})
it('should return existing instance ignoring new config', () => {
fixtureEl.innerHTML = getDummyFixture()
const div = fixtureEl.querySelector('.content')!
const scrollspy = new ScrollSpy(div, { offset: 1 })
const scrollspy2 = ScrollSpy.getOrCreateInstance(div, { offset: 2 })
expect(scrollspy2).toBe(scrollspy)
expect(scrollspy2._config.offset).toBe(1)
})
})
describe('event handler', () => {
it('should create scrollspy on window load event', () => {
fixtureEl.innerHTML = [
'<div id="nav"></div>',
'<div id="wrapper" data-bs-spy="scroll" data-bs-target="#nav" style="overflow-y: auto"></div>'
].join('')
const scrollSpyEl = fixtureEl.querySelector('#wrapper')!
window.dispatchEvent(createEvent('load'))
expect(ScrollSpy.getInstance(scrollSpyEl)).not.toBeNull()
})
})
describe('_observerCallback', () => {
it('should activate target on intersecting entry (scroll down)', () => {
fixtureEl.innerHTML = getDummyFixture()
const div = fixtureEl.querySelector('.content')!
const scrollSpy = new ScrollSpy(div)
const link = fixtureEl.querySelector('#li-jsm-1') as HTMLElement
const section = fixtureEl.querySelector('#div-jsm-1') as HTMLElement
scrollSpy._targetLinks.set('#div-jsm-1', link)
scrollSpy._observableSections.set('#div-jsm-1', section)
scrollSpy._previousScrollData.parentScrollTop = 0
const entry = {
isIntersecting: true,
target: section,
intersectionRatio: 1
} as unknown as IntersectionObserverEntry
Object.defineProperty(section, 'offsetTop', { value: 100, configurable: true })
scrollSpy._observerCallback([entry])
expect(link.classList.contains('active')).toBe(true)
expect(scrollSpy._activeTarget).toBe(link)
})
it('should clear active class on non-intersecting entry', () => {
fixtureEl.innerHTML = getDummyFixture()
const div = fixtureEl.querySelector('.content')!
const scrollSpy = new ScrollSpy(div)
const link = fixtureEl.querySelector('#li-jsm-1') as HTMLElement
const section = fixtureEl.querySelector('#div-jsm-1') as HTMLElement
link.classList.add('active')
scrollSpy._activeTarget = link
scrollSpy._targetLinks.set('#div-jsm-1', link)
const entry = {
isIntersecting: false,
target: section
} as unknown as IntersectionObserverEntry
scrollSpy._observerCallback([entry])
expect(scrollSpy._activeTarget).toBeNull()
})
it('should activate on scroll up when entry is higher', () => {
fixtureEl.innerHTML = getDummyFixture()
const div = fixtureEl.querySelector('.content')!
const scrollSpy = new ScrollSpy(div)
const link = fixtureEl.querySelector('#li-jsm-1') as HTMLElement
const section = fixtureEl.querySelector('#div-jsm-1') as HTMLElement
scrollSpy._targetLinks.set('#div-jsm-1', link)
scrollSpy._observableSections.set('#div-jsm-1', section)
scrollSpy._previousScrollData.parentScrollTop = 200
scrollSpy._previousScrollData.visibleEntryTop = 300
Object.defineProperty(section, 'offsetTop', { value: 100, configurable: true })
Object.defineProperty(div, 'scrollTop', { value: 100, configurable: true, writable: true })
const entry = {
isIntersecting: true,
target: section,
intersectionRatio: 1
} as unknown as IntersectionObserverEntry
scrollSpy._observerCallback([entry])
expect(link.classList.contains('active')).toBe(true)
})
it('should not activate on scroll up when entry is lower', () => {
fixtureEl.innerHTML = getDummyFixture()
const div = fixtureEl.querySelector('.content')!
const scrollSpy = new ScrollSpy(div)
const link = fixtureEl.querySelector('#li-jsm-1') as HTMLElement
const section = fixtureEl.querySelector('#div-jsm-1') as HTMLElement
scrollSpy._targetLinks.set('#div-jsm-1', link)
scrollSpy._observableSections.set('#div-jsm-1', section)
scrollSpy._previousScrollData.parentScrollTop = 200
scrollSpy._previousScrollData.visibleEntryTop = 50
Object.defineProperty(section, 'offsetTop', { value: 100, configurable: true })
Object.defineProperty(div, 'scrollTop', { value: 100, configurable: true, writable: true })
const entry = {
isIntersecting: true,
target: section,
intersectionRatio: 1
} as unknown as IntersectionObserverEntry
scrollSpy._observerCallback([entry])
expect(link.classList.contains('active')).toBe(false)
})
it('should not re-process if activeTarget is same', () => {
fixtureEl.innerHTML = getDummyFixture()
const div = fixtureEl.querySelector('.content')!
const scrollSpy = new ScrollSpy(div)
const link = fixtureEl.querySelector('#li-jsm-1') as HTMLElement
const section = fixtureEl.querySelector('#div-jsm-1') as HTMLElement
scrollSpy._targetLinks.set('#div-jsm-1', link)
scrollSpy._observableSections.set('#div-jsm-1', section)
link.classList.add('active')
scrollSpy._activeTarget = link
scrollSpy._previousScrollData.parentScrollTop = 0
Object.defineProperty(section, 'offsetTop', { value: 100, configurable: true })
const entry = {
isIntersecting: true,
target: section,
intersectionRatio: 1
} as unknown as IntersectionObserverEntry
const spy = vi.fn()
div.addEventListener('activate.bs.scrollspy', spy)
scrollSpy._observerCallback([entry])
expect(spy).not.toHaveBeenCalled()
})
})
describe('_process', () => {
it('should add active class and trigger event', () => {
fixtureEl.innerHTML = getDummyFixture()
const div = fixtureEl.querySelector('.content')!
const scrollSpy = new ScrollSpy(div)
const link = fixtureEl.querySelector('#li-jsm-1') as HTMLElement
const spy = vi.fn()
div.addEventListener('activate.bs.scrollspy', spy)
scrollSpy._process(link)
expect(link.classList.contains('active')).toBe(true)
expect(scrollSpy._activeTarget).toBe(link)
expect(spy).toHaveBeenCalled()
})
})
describe('_activateParents', () => {
it('should activate dropdown-toggle for dropdown-item target', () => {
fixtureEl.innerHTML = [
'<nav class="navbar">',
' <div class="dropdown">',
' <a class="dropdown-toggle" href="#">Dropdown</a>',
' <div class="dropdown-menu">',
' <a class="dropdown-item" id="drop1" href="#one">One</a>',
' </div>',
' </div>',
'</nav>',
'<div class="content" style="overflow-y: auto">',
' <div id="one">one</div>',
'</div>'
].join('')
const div = fixtureEl.querySelector('.content')!
const scrollSpy = new ScrollSpy(div)
const dropItem = fixtureEl.querySelector('#drop1') as HTMLElement
const dropToggle = fixtureEl.querySelector('.dropdown-toggle') as HTMLElement
scrollSpy._activateParents(dropItem)
expect(dropToggle.classList.contains('active')).toBe(true)
})
it('should activate nav parents for nav-link target', () => {
fixtureEl.innerHTML = [
'<nav class="navbar">',
' <nav class="nav">',
' <a class="nav-link" id="a1" href="#one">One</a>',
' </nav>',
'</nav>',
'<div class="content" style="overflow-y: auto">',
' <div id="one">one</div>',
'</div>'
].join('')
const div = fixtureEl.querySelector('.content')!
const scrollSpy = new ScrollSpy(div)
const link = fixtureEl.querySelector('#a1') as HTMLElement
scrollSpy._activateParents(link)
// nav-link itself is handled by _process, parents via SelectorEngine.prev
expect(link).toBeDefined()
})
})
describe('_clearActiveClass', () => {
it('should remove active class from parent and children', () => {
fixtureEl.innerHTML = [
'<nav class="navbar active">',
' <a class="nav-link active" href="#one">One</a>',
' <a class="nav-link active" href="#two">Two</a>',
'</nav>',
'<div class="content" style="overflow-y: auto">',
' <div id="one">one</div>',
'</div>'
].join('')
const div = fixtureEl.querySelector('.content')!
const scrollSpy = new ScrollSpy(div)
const navbar = fixtureEl.querySelector('.navbar') as HTMLElement
scrollSpy._clearActiveClass(navbar)
expect(navbar.classList.contains('active')).toBe(false)
const activeLinks = fixtureEl.querySelectorAll('.nav-link.active')
expect(activeLinks).toHaveLength(0)
})
})
describe('_initializeTargetsAndObservables', () => {
it('should populate maps when sections are visible', () => {
fixtureEl.innerHTML = getDummyFixture()
const div = fixtureEl.querySelector('.content')!
const scrollSpy = new ScrollSpy(div)
const section = fixtureEl.querySelector('#div-jsm-1') as HTMLElement
Object.defineProperty(section, 'getClientRects', {
value: () => [{ width: 100, height: 100 }],
configurable: true
})
scrollSpy._initializeTargetsAndObservables()
expect(scrollSpy._targetLinks.size).toBe(1)
expect(scrollSpy._observableSections.size).toBe(1)
})
it('should skip disabled anchors', () => {
fixtureEl.innerHTML = [
'<nav id="navBar" class="navbar">',
' <ul class="nav">',
' <a class="nav-link" href="#div1" disabled>div 1</a>',
' <a class="nav-link disabled" href="#div2">div 2</a>',
' </ul>',
'</nav>',
'<div class="content" style="overflow-y: auto">',
' <div id="div1">div 1</div>',
' <div id="div2">div 2</div>',
'</div>'
].join('')
const div = fixtureEl.querySelector('.content')!
const scrollSpy = new ScrollSpy(div)
expect(scrollSpy._targetLinks.size).toBe(0)
})
})
describe('_activateParents (nav prev)', () => {
it('should activate prev sibling nav-link in list-group', () => {
fixtureEl.innerHTML = [
'<nav class="navbar">',
' <div class="list-group">',
' <a class="list-group-item" id="a1" href="#one">One</a>',
' <a class="list-group-item" id="a2" href="#two">Two</a>',
' </div>',
'</nav>',
'<div class="content" style="overflow-y: auto">',
' <div id="one">one</div>',
' <div id="two">two</div>',
'</div>'
].join('')
const div = fixtureEl.querySelector('.content')!
const scrollSpy = new ScrollSpy(div)
const link2 = fixtureEl.querySelector('#a2') as HTMLElement
scrollSpy._activateParents(link2)
// list-group-item doesn't have .dropdown-item, so it goes through nav parents path
expect(link2).toBeDefined()
})
})
describe('smoothScroll', () => {
it('should not enable smoothScroll by default', () => {
fixtureEl.innerHTML = getDummyFixture()
const div = fixtureEl.querySelector('.content') as HTMLElement
div.scrollTo = vi.fn()
new ScrollSpy(div, { offset: 1 })
const link = fixtureEl.querySelector('[href="#div-jsm-1"]') as HTMLElement
link.click()
expect(div.scrollTo).not.toHaveBeenCalled()
})
it('should scrollTo observable section on anchor click', () => {
fixtureEl.innerHTML = getDummyFixture()
const div = fixtureEl.querySelector('.content') as HTMLElement
div.scrollTo = vi.fn()
const section = fixtureEl.querySelector('#div-jsm-1') as HTMLElement
Object.defineProperty(section, 'getClientRects', {
value: () => [{ width: 100, height: 100 }],
configurable: true
})
const scrollSpy = new ScrollSpy(div, { offset: 1, smoothScroll: true })
scrollSpy._initializeTargetsAndObservables()
const link = fixtureEl.querySelector('[href="#div-jsm-1"]') as HTMLElement
link.click()
expect(div.scrollTo).toHaveBeenCalled()
})
it('should fallback to scrollTop if scrollTo is not available', () => {
fixtureEl.innerHTML = getDummyFixture()
const div = fixtureEl.querySelector('.content') as HTMLElement
const section = fixtureEl.querySelector('#div-jsm-1') as HTMLElement
Object.defineProperty(section, 'getClientRects', {
value: () => [{ width: 100, height: 100 }],
configurable: true
})
// Manually set _rootElement to a plain object without scrollTo
const scrollSpy = new ScrollSpy(div, { offset: 1, smoothScroll: true })
scrollSpy._initializeTargetsAndObservables()
// Remove scrollTo to test fallback
delete (div as any).scrollTo
scrollSpy._rootElement = div
// Re-enable smoothScroll handler
scrollSpy._maybeEnableSmoothScroll()
const link = fixtureEl.querySelector('[href="#div-jsm-1"]') as HTMLElement
link.click()
// Should not throw - scrollTop assignment is the fallback
expect(ScrollSpy.getInstance(div)).not.toBeNull()
})
it('should not scroll if section not found in observables', () => {
fixtureEl.innerHTML = [
'<nav id="navBar" class="navbar">',
' <ul class="nav">',
' <a id="anchor-1" href="#div-jsm-1">div 1</a>',
' <a id="anchor-2" href="#foo">div 2</a>',
' </ul>',
'</nav>',
'<div class="content" data-bs-target="#navBar" style="overflow-y: auto">',
' <div id="div-jsm-1">div 1</div>',
'</div>'
].join('')
const div = fixtureEl.querySelector('.content') as HTMLElement
div.scrollTo = vi.fn()
new ScrollSpy(div, { offset: 1, smoothScroll: true })
const anchor2 = fixtureEl.querySelector('#anchor-2') as HTMLElement
anchor2.click()
expect(div.scrollTo).not.toHaveBeenCalled()
})
})
describe('data-tblr-spy', () => {
it('should create scrollspy on window load with data-tblr-spy="scroll"', () => {
fixtureEl.innerHTML = [
'<div id="nav"></div>',
'<div id="wrapper" data-tblr-spy="scroll" data-bs-target="#nav" style="overflow-y: auto"></div>'
].join('')
const scrollSpyEl = fixtureEl.querySelector('#wrapper')!
window.dispatchEvent(createEvent('load'))
expect(ScrollSpy.getInstance(scrollSpyEl)).not.toBeNull()
})
})
})

View File

@@ -0,0 +1,849 @@
import { describe, it, expect, beforeAll, afterEach, vi } from 'vitest'
import Tab from '../../src/bootstrap/tab'
import { clearFixture, createEvent, getFixture } from '../helpers/fixture'
describe('Tab', () => {
let fixtureEl: HTMLElement
beforeAll(() => {
fixtureEl = getFixture()
})
afterEach(() => {
clearFixture()
vi.restoreAllMocks()
})
describe('VERSION', () => {
it('should return plugin version', () => {
expect(typeof Tab.VERSION).toBe('string')
})
})
describe('constructor', () => {
it('should accept element as CSS selector or DOM element', () => {
fixtureEl.innerHTML = [
'<ul class="nav">',
' <li><a href="#home" role="tab">Home</a></li>',
'</ul>',
'<ul>',
' <li id="home"></li>',
'</ul>'
].join('')
const tabEl = fixtureEl.querySelector('[href="#home"]')!
const tabBySelector = new Tab('[href="#home"]')
const tabByElement = new Tab(tabEl)
expect(tabBySelector._element).toBe(tabEl)
expect(tabByElement._element).toBe(tabEl)
})
it('should not throw if no parent', () => {
fixtureEl.innerHTML = '<div class=""><div class="nav-link"></div></div>'
const navEl = fixtureEl.querySelector('.nav-link')!
expect(() => {
new Tab(navEl)
}).not.toThrow()
})
})
describe('show', () => {
it('should activate element by tab id using buttons', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<ul class="nav" role="tablist">',
' <li><button type="button" data-bs-target="#home" role="tab">Home</button></li>',
' <li><button type="button" id="triggerProfile" data-bs-target="#profile" role="tab">Profile</button></li>',
'</ul>',
'<ul>',
' <li id="home" role="tabpanel"></li>',
' <li id="profile" role="tabpanel"></li>',
'</ul>'
].join('')
const profileTriggerEl = fixtureEl.querySelector('#triggerProfile')!
const tab = new Tab(profileTriggerEl)
profileTriggerEl.addEventListener('shown.bs.tab', () => {
expect(fixtureEl.querySelector('#profile')!.classList.contains('active')).toBe(true)
expect(profileTriggerEl.getAttribute('aria-selected')).toBe('true')
resolve()
})
tab.show()
})
})
it('should activate element by tab id using links', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<ul class="nav" role="tablist">',
' <li><a href="#home" role="tab">Home</a></li>',
' <li><a id="triggerProfile" href="#profile" role="tab">Profile</a></li>',
'</ul>',
'<ul>',
' <li id="home" role="tabpanel"></li>',
' <li id="profile" role="tabpanel"></li>',
'</ul>'
].join('')
const profileTriggerEl = fixtureEl.querySelector('#triggerProfile')!
const tab = new Tab(profileTriggerEl)
profileTriggerEl.addEventListener('shown.bs.tab', () => {
expect(fixtureEl.querySelector('#profile')!.classList.contains('active')).toBe(true)
expect(profileTriggerEl.getAttribute('aria-selected')).toBe('true')
resolve()
})
tab.show()
})
})
it('should activate element in list group', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<div class="list-group" role="tablist">',
' <button type="button" data-bs-target="#home" role="tab">Home</button>',
' <button type="button" id="triggerProfile" data-bs-target="#profile" role="tab">Profile</button>',
'</div>',
'<div>',
' <div id="home" role="tabpanel"></div>',
' <div id="profile" role="tabpanel"></div>',
'</div>'
].join('')
const profileTriggerEl = fixtureEl.querySelector('#triggerProfile')!
const tab = new Tab(profileTriggerEl)
profileTriggerEl.addEventListener('shown.bs.tab', () => {
expect(fixtureEl.querySelector('#profile')!.classList.contains('active')).toBe(true)
resolve()
})
tab.show()
})
})
it('should not fire shown when show is prevented', () => {
return new Promise<void>((resolve, reject) => {
fixtureEl.innerHTML = '<div class="nav"><div class="nav-link"></div></div>'
const navEl = fixtureEl.querySelector('.nav > div')!
const tab = new Tab(navEl)
navEl.addEventListener('show.bs.tab', ev => {
ev.preventDefault()
setTimeout(resolve, 30)
})
navEl.addEventListener('shown.bs.tab', () => {
reject(new Error('should not trigger shown'))
})
tab.show()
})
})
it('should not fire shown when already active', () => {
return new Promise<void>((resolve, reject) => {
fixtureEl.innerHTML = [
'<ul class="nav nav-tabs" role="tablist">',
' <li class="nav-item" role="presentation"><button type="button" data-bs-target="#home" class="nav-link active" role="tab" aria-selected="true">Home</button></li>',
' <li class="nav-item" role="presentation"><button type="button" data-bs-target="#profile" class="nav-link" role="tab">Profile</button></li>',
'</ul>',
'<div class="tab-content">',
' <div class="tab-pane active" id="home" role="tabpanel"></div>',
' <div class="tab-pane" id="profile" role="tabpanel"></div>',
'</div>'
].join('')
const triggerActive = fixtureEl.querySelector('button.active')!
const tab = new Tab(triggerActive)
triggerActive.addEventListener('shown.bs.tab', () => {
reject(new Error('should not trigger shown'))
})
tab.show()
setTimeout(resolve, 30)
})
})
it('show and shown events should reference correct relatedTarget', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<ul class="nav nav-tabs" role="tablist">',
' <li class="nav-item" role="presentation"><button type="button" data-bs-target="#home" class="nav-link active" role="tab" aria-selected="true">Home</button></li>',
' <li class="nav-item" role="presentation"><button type="button" id="triggerProfile" data-bs-target="#profile" class="nav-link" role="tab">Profile</button></li>',
'</ul>',
'<div class="tab-content">',
' <div class="tab-pane active" id="home" role="tabpanel"></div>',
' <div class="tab-pane" id="profile" role="tabpanel"></div>',
'</div>'
].join('')
const secondTabTrigger = fixtureEl.querySelector('#triggerProfile')!
const secondTab = new Tab(secondTabTrigger)
secondTabTrigger.addEventListener('show.bs.tab', ((ev: CustomEvent) => {
expect(ev.relatedTarget!.getAttribute('data-bs-target')).toBe('#home')
}) as EventListener)
secondTabTrigger.addEventListener('shown.bs.tab', ((ev: CustomEvent) => {
expect(ev.relatedTarget!.getAttribute('data-bs-target')).toBe('#home')
expect(secondTabTrigger.getAttribute('aria-selected')).toBe('true')
resolve()
}) as EventListener)
secondTab.show()
})
})
it('should fire hide and hidden events', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<ul class="nav" role="tablist">',
' <li><button type="button" data-bs-target="#home" role="tab">Home</button></li>',
' <li><button type="button" data-bs-target="#profile" role="tab">Profile</button></li>',
'</ul>'
].join('')
const triggerList = fixtureEl.querySelectorAll('button')
const firstTab = new Tab(triggerList[0])
const secondTab = new Tab(triggerList[1])
let hideCalled = false
triggerList[0].addEventListener('shown.bs.tab', () => {
secondTab.show()
})
triggerList[0].addEventListener('hide.bs.tab', ((ev: CustomEvent) => {
hideCalled = true
expect(ev.relatedTarget!.getAttribute('data-bs-target')).toBe('#profile')
}) as EventListener)
triggerList[0].addEventListener('hidden.bs.tab', ((ev: CustomEvent) => {
expect(hideCalled).toBe(true)
expect(ev.relatedTarget!.getAttribute('data-bs-target')).toBe('#profile')
resolve()
}) as EventListener)
firstTab.show()
})
})
it('should not fire hidden when hide is prevented', () => {
return new Promise<void>((resolve, reject) => {
fixtureEl.innerHTML = [
'<ul class="nav" role="tablist">',
' <li><button type="button" data-bs-target="#home" role="tab">Home</button></li>',
' <li><button type="button" data-bs-target="#profile" role="tab">Profile</button></li>',
'</ul>'
].join('')
const triggerList = fixtureEl.querySelectorAll('button')
const firstTab = new Tab(triggerList[0])
const secondTab = new Tab(triggerList[1])
triggerList[0].addEventListener('shown.bs.tab', () => {
secondTab.show()
})
triggerList[0].addEventListener('hide.bs.tab', ev => {
ev.preventDefault()
setTimeout(resolve, 30)
})
triggerList[0].addEventListener('hidden.bs.tab', () => {
reject(new Error('should not trigger hidden'))
})
firstTab.show()
})
})
})
describe('dispose', () => {
it('should dispose a tab', () => {
fixtureEl.innerHTML = '<div class="nav"><div class="nav-link"></div></div>'
const el = fixtureEl.querySelector('.nav > div')!
const tab = new Tab(el)
expect(Tab.getInstance(el)).not.toBeNull()
tab.dispose()
expect(Tab.getInstance(el)).toBeNull()
})
})
describe('_activate', () => {
it('should not be called if element is null', () => {
fixtureEl.innerHTML = [
'<ul class="nav" role="tablist">',
' <li class="nav-link"></li>',
'</ul>'
].join('')
const tabEl = fixtureEl.querySelector('.nav-link')!
const tab = new Tab(tabEl)
const spy = vi.spyOn(tab, '_queueCallback')
tab._activate(null)
expect(spy).not.toHaveBeenCalled()
})
})
describe('_setInitialAttributes', () => {
it('should set aria attributes', () => {
fixtureEl.innerHTML = [
'<ul class="nav">',
' <li class="nav-link" id="foo" data-bs-target="#panel"></li>',
' <li class="nav-link" data-bs-target="#panel2"></li>',
'</ul>',
'<div id="panel"></div>',
'<div id="panel2"></div>'
].join('')
const tabEl = fixtureEl.querySelector('.nav-link')!
const parent = fixtureEl.querySelector('.nav') as HTMLElement
const children = Array.from(fixtureEl.querySelectorAll('.nav-link')) as HTMLElement[]
const tabPanel = fixtureEl.querySelector('#panel')!
const tabPanel2 = fixtureEl.querySelector('#panel2')!
expect(parent.getAttribute('role')).toBeNull()
const tab = new Tab(tabEl)
tab._setInitialAttributes(parent, children)
expect(parent.getAttribute('role')).toBe('tablist')
expect(tabEl.getAttribute('role')).toBe('tab')
expect(tabPanel.getAttribute('role')).toBe('tabpanel')
expect(tabPanel2.getAttribute('role')).toBe('tabpanel')
expect(tabPanel.getAttribute('aria-labelledby')).toBe('foo')
expect(tabPanel2.hasAttribute('aria-labelledby')).toBe(false)
})
})
describe('_keydown', () => {
it('should ignore non-arrow keys', () => {
fixtureEl.innerHTML = [
'<ul class="nav">',
' <li class="nav-link" data-bs-toggle="tab"></li>',
'</ul>'
].join('')
const tabEl = fixtureEl.querySelector('.nav-link')!
const tab = new Tab(tabEl)
const spyStop = vi.spyOn(Event.prototype, 'stopPropagation')
const spyPrevent = vi.spyOn(Event.prototype, 'preventDefault')
const keydown = createEvent('keydown') as any
keydown.key = 'Enter'
tabEl.dispatchEvent(keydown)
expect(spyStop).not.toHaveBeenCalled()
expect(spyPrevent).not.toHaveBeenCalled()
})
it('should handle right/down arrow', () => {
fixtureEl.innerHTML = [
'<div class="nav">',
' <span id="tab1" class="nav-link" data-bs-toggle="tab"></span>',
' <span id="tab2" class="nav-link" data-bs-toggle="tab"></span>',
' <span id="tab3" class="nav-link" data-bs-toggle="tab"></span>',
'</div>'
].join('')
const tabEl1 = fixtureEl.querySelector('#tab1')!
const tabEl2 = fixtureEl.querySelector('#tab2')!
const tabEl3 = fixtureEl.querySelector('#tab3')!
new Tab(tabEl1)
new Tab(tabEl2)
new Tab(tabEl3)
const spyFocus2 = vi.spyOn(tabEl2, 'focus')
const spyFocus3 = vi.spyOn(tabEl3, 'focus')
const keydown = new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })
tabEl1.dispatchEvent(keydown)
expect(spyFocus2).toHaveBeenCalled()
})
it('should handle left/up arrow', () => {
fixtureEl.innerHTML = [
'<div class="nav">',
' <span id="tab1" class="nav-link" data-bs-toggle="tab"></span>',
' <span id="tab2" class="nav-link" data-bs-toggle="tab"></span>',
'</div>'
].join('')
const tabEl1 = fixtureEl.querySelector('#tab1')!
const tabEl2 = fixtureEl.querySelector('#tab2')!
new Tab(tabEl1)
new Tab(tabEl2)
const spyFocus1 = vi.spyOn(tabEl1, 'focus')
const keydown = new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true })
tabEl2.dispatchEvent(keydown)
expect(spyFocus1).toHaveBeenCalled()
})
it('should handle Home key', () => {
fixtureEl.innerHTML = [
'<div class="nav">',
' <span id="tab1" class="nav-link" data-bs-toggle="tab"></span>',
' <span id="tab2" class="nav-link" data-bs-toggle="tab"></span>',
' <span id="tab3" class="nav-link" data-bs-toggle="tab"></span>',
'</div>'
].join('')
const tabEl1 = fixtureEl.querySelector('#tab1')!
const tabEl3 = fixtureEl.querySelector('#tab3')!
new Tab(tabEl1)
new Tab(tabEl3)
const spyFocus1 = vi.spyOn(tabEl1, 'focus')
const keydown = new KeyboardEvent('keydown', { key: 'Home', bubbles: true })
tabEl3.dispatchEvent(keydown)
expect(spyFocus1).toHaveBeenCalled()
})
it('should handle End key', () => {
fixtureEl.innerHTML = [
'<div class="nav">',
' <span id="tab1" class="nav-link" data-bs-toggle="tab"></span>',
' <span id="tab2" class="nav-link" data-bs-toggle="tab"></span>',
' <span id="tab3" class="nav-link" data-bs-toggle="tab"></span>',
'</div>'
].join('')
const tabEl1 = fixtureEl.querySelector('#tab1')!
const tabEl3 = fixtureEl.querySelector('#tab3')!
new Tab(tabEl1)
new Tab(tabEl3)
const spyFocus3 = vi.spyOn(tabEl3, 'focus')
const keydown = new KeyboardEvent('keydown', { key: 'End', bubbles: true })
tabEl1.dispatchEvent(keydown)
expect(spyFocus3).toHaveBeenCalled()
})
it('should skip disabled elements', () => {
fixtureEl.innerHTML = [
'<div class="nav">',
' <span id="tab1" class="nav-link" data-bs-toggle="tab"></span>',
' <span id="tab2" class="nav-link" data-bs-toggle="tab" disabled></span>',
' <span id="tab3" class="nav-link disabled" data-bs-toggle="tab"></span>',
' <span id="tab4" class="nav-link" data-bs-toggle="tab"></span>',
'</div>'
].join('')
const tabEl1 = fixtureEl.querySelector('#tab1')!
const tabEl4 = fixtureEl.querySelector('#tab4')!
new Tab(tabEl1)
new Tab(tabEl4)
const spyFocus4 = vi.spyOn(tabEl4, 'focus')
const keydown = new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })
tabEl1.dispatchEvent(keydown)
expect(spyFocus4).toHaveBeenCalled()
})
})
describe('getInstance', () => {
it('should return null if no instance', () => {
expect(Tab.getInstance(fixtureEl)).toBeNull()
})
it('should return this instance', () => {
fixtureEl.innerHTML = '<div class="nav"><div class="nav-link"></div></div>'
const divEl = fixtureEl.querySelector('.nav > div')!
const tab = new Tab(divEl)
expect(Tab.getInstance(divEl)).toBe(tab)
expect(Tab.getInstance(divEl)).toBeInstanceOf(Tab)
})
})
describe('getOrCreateInstance', () => {
it('should return tab instance', () => {
fixtureEl.innerHTML = '<div class="nav"><div class="nav-link"></div></div>'
const div = fixtureEl.querySelector('div')!
const tab = new Tab(div)
expect(Tab.getOrCreateInstance(div)).toBe(tab)
expect(Tab.getOrCreateInstance(div)).toBeInstanceOf(Tab)
})
it('should return new instance when there is no tab instance', () => {
fixtureEl.innerHTML = '<div class="nav"><div class="nav-link"></div></div>'
const div = fixtureEl.querySelector('div')!
expect(Tab.getInstance(div)).toBeNull()
expect(Tab.getOrCreateInstance(div)).toBeInstanceOf(Tab)
})
})
describe('data-api', () => {
it('should create dynamically a tab', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<ul class="nav nav-tabs" role="tablist">',
' <li class="nav-item" role="presentation"><button type="button" data-bs-target="#home" class="nav-link active" role="tab" aria-selected="true">Home</button></li>',
' <li class="nav-item" role="presentation"><button type="button" id="triggerProfile" data-bs-toggle="tab" data-bs-target="#profile" class="nav-link" role="tab">Profile</button></li>',
'</ul>',
'<div class="tab-content">',
' <div class="tab-pane active" id="home" role="tabpanel"></div>',
' <div class="tab-pane" id="profile" role="tabpanel"></div>',
'</div>'
].join('')
const secondTabTrigger = fixtureEl.querySelector('#triggerProfile')!
secondTabTrigger.addEventListener('shown.bs.tab', () => {
expect(secondTabTrigger.classList.contains('active')).toBe(true)
expect(fixtureEl.querySelector('#profile')!.classList.contains('active')).toBe(true)
resolve()
})
;(secondTabTrigger as HTMLElement).click()
})
})
it('should prevent default when trigger is <a>', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<ul class="nav" role="tablist">',
' <li><a type="button" href="#test" class="active" role="tab" data-bs-toggle="tab">Home</a></li>',
' <li><a type="button" href="#test2" role="tab" data-bs-toggle="tab">Profile</a></li>',
'</ul>'
].join('')
const tabEl = fixtureEl.querySelector('[href="#test2"]') as HTMLElement
const spy = vi.spyOn(Event.prototype, 'preventDefault')
tabEl.addEventListener('shown.bs.tab', () => {
expect(tabEl.classList.contains('active')).toBe(true)
expect(spy).toHaveBeenCalled()
resolve()
})
tabEl.click()
})
})
it('should not fire shown for disabled button', () => {
return new Promise<void>((resolve, reject) => {
fixtureEl.innerHTML = [
'<ul class="nav nav-tabs" role="tablist">',
' <li class="nav-item" role="presentation"><button type="button" data-bs-target="#home" class="nav-link active" role="tab">Home</button></li>',
' <li class="nav-item" role="presentation"><button type="button" data-bs-target="#profile" class="nav-link" disabled role="tab" data-bs-toggle="tab">Profile</button></li>',
'</ul>'
].join('')
const triggerDisabled = fixtureEl.querySelector('button[disabled]')!
triggerDisabled.addEventListener('shown.bs.tab', () => {
reject(new Error('should not fire shown'))
})
;(triggerDisabled as HTMLElement).click()
setTimeout(resolve, 30)
})
})
it('should not fire shown for disabled link', () => {
return new Promise<void>((resolve, reject) => {
fixtureEl.innerHTML = [
'<ul class="nav nav-tabs" role="tablist">',
' <li class="nav-item" role="presentation"><a href="#home" class="nav-link active" role="tab" data-bs-toggle="tab">Home</a></li>',
' <li class="nav-item" role="presentation"><a href="#profile" class="nav-link disabled" role="tab" data-bs-toggle="tab">Profile</a></li>',
'</ul>'
].join('')
const triggerDisabled = fixtureEl.querySelector('a.disabled')!
triggerDisabled.addEventListener('shown.bs.tab', () => {
reject(new Error('should not fire shown'))
})
;(triggerDisabled as HTMLElement).click()
setTimeout(resolve, 30)
})
})
it('should handle nested tabs', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<nav class="nav nav-tabs" role="tablist">',
' <button type="button" id="tab1" data-bs-target="#x-tab1" class="nav-link" data-bs-toggle="tab" role="tab">Tab 1</button>',
' <button type="button" data-bs-target="#x-tab2" class="nav-link active" data-bs-toggle="tab" role="tab" aria-selected="true">Tab 2</button>',
'</nav>',
'<div class="tab-content">',
' <div class="tab-pane" id="x-tab1" role="tabpanel">',
' <nav class="nav nav-tabs" role="tablist">',
' <button type="button" data-bs-target="#nested-tab1" class="nav-link active" data-bs-toggle="tab" role="tab" aria-selected="true">Nested 1</button>',
' <button type="button" id="tabNested2" data-bs-target="#nested-tab2" class="nav-link" data-bs-toggle="tab" role="tab">Nested 2</button>',
' </nav>',
' <div class="tab-content">',
' <div class="tab-pane active" id="nested-tab1" role="tabpanel">Nested 1</div>',
' <div class="tab-pane" id="nested-tab2" role="tabpanel">Nested 2</div>',
' </div>',
' </div>',
' <div class="tab-pane active" id="x-tab2" role="tabpanel">Tab2</div>',
'</div>'
].join('')
const tab1El = fixtureEl.querySelector('#tab1') as HTMLElement
const tabNested2El = fixtureEl.querySelector('#tabNested2') as HTMLElement
const xTab1El = fixtureEl.querySelector('#x-tab1')!
tabNested2El.addEventListener('shown.bs.tab', () => {
expect(xTab1El.classList.contains('active')).toBe(true)
resolve()
})
tab1El.addEventListener('shown.bs.tab', () => {
expect(xTab1El.classList.contains('active')).toBe(true)
tabNested2El.click()
})
tab1El.click()
})
})
it('selected tab should deactivate previous selected link in dropdown', () => {
fixtureEl.innerHTML = [
'<ul class="nav nav-tabs">',
' <li class="nav-item"><a class="nav-link" href="#home" data-bs-toggle="tab">Home</a></li>',
' <li class="nav-item"><a class="nav-link" href="#profile" data-bs-toggle="tab">Profile</a></li>',
' <li class="nav-item dropdown">',
' <a class="nav-link dropdown-toggle active" data-bs-toggle="dropdown" href="#">Dropdown</a>',
' <div class="dropdown-menu">',
' <a class="dropdown-item active" href="#dropdown1" id="dropdown1-tab" data-bs-toggle="tab">@fat</a>',
' <a class="dropdown-item" href="#dropdown2" id="dropdown2-tab" data-bs-toggle="tab">@mdo</a>',
' </div>',
' </li>',
'</ul>'
].join('')
const firstLiLinkEl = fixtureEl.querySelector('li:first-child a') as HTMLElement
firstLiLinkEl.click()
expect(firstLiLinkEl.classList.contains('active')).toBe(true)
expect(fixtureEl.querySelector('li:last-child a')!.classList.contains('active')).toBe(false)
expect(fixtureEl.querySelector('li:last-child .dropdown-menu a:first-child')!.classList.contains('active')).toBe(false)
})
it('selecting dropdown tab does not activate another nav', () => {
fixtureEl.innerHTML = [
'<ul class="nav nav-tabs" id="nav1">',
' <li class="nav-item active"><a class="nav-link" href="#home" data-bs-toggle="tab">Home</a></li>',
' <li class="nav-item dropdown">',
' <a class="nav-link dropdown-toggle" data-bs-toggle="dropdown" href="#">Dropdown</a>',
' <div class="dropdown-menu">',
' <a class="dropdown-item" href="#dropdown1" id="dropdown1-tab" data-bs-toggle="tab">@fat</a>',
' </div>',
' </li>',
'</ul>',
'<ul class="nav nav-tabs" id="nav2">',
' <li class="nav-item active"><a class="nav-link" href="#home2" data-bs-toggle="tab">Home</a></li>',
' <li class="nav-item dropdown">',
' <a class="nav-link dropdown-toggle" data-bs-toggle="dropdown" href="#">Dropdown</a>',
' <div class="dropdown-menu">',
' <a class="dropdown-item" href="#dropdown2" id="dropdown2-tab" data-bs-toggle="tab">@fat</a>',
' </div>',
' </li>',
'</ul>'
].join('')
const firstDropItem = fixtureEl.querySelector('#nav1 .dropdown-item') as HTMLElement
firstDropItem.click()
expect(firstDropItem.classList.contains('active')).toBe(true)
expect(fixtureEl.querySelector('#nav1 .dropdown-toggle')!.classList.contains('active')).toBe(true)
expect(fixtureEl.querySelector('#nav2 .dropdown-toggle')!.classList.contains('active')).toBe(false)
expect(fixtureEl.querySelector('#nav2 .dropdown-item')!.classList.contains('active')).toBe(false)
})
it('should support li > .dropdown-item', () => {
fixtureEl.innerHTML = [
'<ul class="nav nav-tabs">',
' <li class="nav-item"><a class="nav-link active" href="#home" data-bs-toggle="tab">Home</a></li>',
' <li class="nav-item dropdown">',
' <a class="nav-link dropdown-toggle" data-bs-toggle="dropdown" href="#">Dropdown</a>',
' <ul class="dropdown-menu">',
' <li><a class="dropdown-item" href="#dropdown1" data-bs-toggle="tab">@fat</a></li>',
' <li><a class="dropdown-item" href="#dropdown2" data-bs-toggle="tab">@mdo</a></li>',
' </ul>',
' </li>',
'</ul>'
].join('')
const dropItems = fixtureEl.querySelectorAll('.dropdown-item')
;(dropItems[1] as HTMLElement).click()
expect(dropItems[0].classList.contains('active')).toBe(false)
expect(dropItems[1].classList.contains('active')).toBe(true)
expect(fixtureEl.querySelector('.nav-link')!.classList.contains('active')).toBe(false)
})
it('should add show class to pane without fade', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<ul class="nav nav-tabs" role="tablist">',
' <li class="nav-item" role="presentation">',
' <button type="button" class="nav-link" data-bs-target="#home" role="tab" data-bs-toggle="tab">Home</button>',
' </li>',
' <li class="nav-item" role="presentation">',
' <button type="button" id="secondNav" class="nav-link" data-bs-target="#profile" role="tab" data-bs-toggle="tab">Profile</button>',
' </li>',
'</ul>',
'<div class="tab-content">',
' <div role="tabpanel" class="tab-pane" id="home">test 1</div>',
' <div role="tabpanel" class="tab-pane" id="profile">test 2</div>',
'</div>'
].join('')
const secondNavEl = fixtureEl.querySelector('#secondNav') as HTMLElement
secondNavEl.addEventListener('shown.bs.tab', () => {
expect(fixtureEl.querySelectorAll('.tab-content .show')).toHaveLength(1)
resolve()
})
secondNavEl.click()
})
})
})
describe('data-tblr-toggle', () => {
it('should create tab via data-tblr-toggle="tab"', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<ul class="nav nav-tabs" role="tablist">',
' <li class="nav-item" role="presentation"><button type="button" data-bs-target="#home" class="nav-link active" role="tab" aria-selected="true">Home</button></li>',
' <li class="nav-item" role="presentation"><button type="button" id="triggerProfile" data-tblr-toggle="tab" data-bs-target="#profile" class="nav-link" role="tab">Profile</button></li>',
'</ul>',
'<div class="tab-content">',
' <div class="tab-pane active" id="home" role="tabpanel"></div>',
' <div class="tab-pane" id="profile" role="tabpanel"></div>',
'</div>'
].join('')
const trigger = fixtureEl.querySelector('#triggerProfile') as HTMLElement
trigger.addEventListener('shown.bs.tab', () => {
expect(trigger.classList.contains('active')).toBe(true)
expect(fixtureEl.querySelector('#profile')!.classList.contains('active')).toBe(true)
resolve()
})
trigger.click()
})
})
it('should create tab via data-tblr-toggle="pill"', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<ul class="nav nav-pills" role="tablist">',
' <li class="nav-item" role="presentation"><button type="button" data-bs-target="#home" class="nav-link active" role="tab" aria-selected="true">Home</button></li>',
' <li class="nav-item" role="presentation"><button type="button" id="triggerProfile" data-tblr-toggle="pill" data-bs-target="#profile" class="nav-link" role="tab">Profile</button></li>',
'</ul>',
'<div class="tab-content">',
' <div class="tab-pane active" id="home" role="tabpanel"></div>',
' <div class="tab-pane" id="profile" role="tabpanel"></div>',
'</div>'
].join('')
const trigger = fixtureEl.querySelector('#triggerProfile') as HTMLElement
trigger.addEventListener('shown.bs.tab', () => {
expect(trigger.classList.contains('active')).toBe(true)
resolve()
})
trigger.click()
})
})
it('should initialize active tabs with data-tblr-toggle on load', () => {
fixtureEl.innerHTML = [
'<ul class="nav" role="tablist">',
' <li><button class="nav-link active" data-tblr-toggle="tab" data-bs-target="#home" role="tab">Home</button></li>',
'</ul>',
'<div id="home" role="tabpanel"></div>'
].join('')
const trigger = fixtureEl.querySelector('button') as HTMLElement
window.dispatchEvent(new Event('load'))
expect(trigger.classList.contains('active')).toBe(true)
})
it('should create tab via data-tblr-toggle="list"', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<div class="list-group" role="tablist">',
' <a class="list-group-item list-group-item-action active" data-tblr-toggle="list" href="#home" role="tab">Home</a>',
' <a id="triggerProfile" class="list-group-item list-group-item-action" data-tblr-toggle="list" href="#profile" role="tab">Profile</a>',
'</div>',
'<div class="tab-content">',
' <div class="tab-pane active" id="home" role="tabpanel">Home</div>',
' <div class="tab-pane" id="profile" role="tabpanel">Profile</div>',
'</div>'
].join('')
const trigger = fixtureEl.querySelector('#triggerProfile') as HTMLElement
trigger.addEventListener('shown.bs.tab', () => {
expect(trigger.classList.contains('active')).toBe(true)
resolve()
})
trigger.click()
})
})
it('should switch tabs via data-tblr-toggle with data-tblr-target', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<ul class="nav" role="tablist">',
' <li><button class="nav-link active" data-tblr-toggle="tab" data-tblr-target="#home" role="tab">Home</button></li>',
' <li><button id="triggerProfile" class="nav-link" data-tblr-toggle="tab" data-tblr-target="#profile" role="tab">Profile</button></li>',
'</ul>',
'<div class="tab-content">',
' <div class="tab-pane active" id="home" role="tabpanel">Home</div>',
' <div class="tab-pane" id="profile" role="tabpanel">Profile</div>',
'</div>'
].join('')
const trigger = fixtureEl.querySelector('#triggerProfile') as HTMLElement
trigger.addEventListener('shown.bs.tab', () => {
expect(trigger.classList.contains('active')).toBe(true)
resolve()
})
trigger.click()
})
})
})
})

View File

@@ -0,0 +1,587 @@
import { describe, it, expect, beforeAll, afterEach, vi } from 'vitest'
import Toast from '../../src/bootstrap/toast'
import { clearFixture, createEvent, getFixture } from '../helpers/fixture'
describe('Toast', () => {
let fixtureEl: HTMLElement
beforeAll(() => {
fixtureEl = getFixture()
})
afterEach(() => {
clearFixture()
})
describe('VERSION', () => {
it('should return plugin version', () => {
expect(typeof Toast.VERSION).toBe('string')
})
})
describe('DATA_KEY', () => {
it('should return plugin data key', () => {
expect(Toast.DATA_KEY).toBe('bs.toast')
})
})
describe('constructor', () => {
it('should accept element as CSS selector or DOM element', () => {
fixtureEl.innerHTML = '<div class="toast"></div>'
const toastEl = fixtureEl.querySelector('.toast')!
const toastBySelector = new Toast('.toast')
const toastByElement = new Toast(toastEl)
expect(toastBySelector._element).toBe(toastEl)
expect(toastByElement._element).toBe(toastEl)
})
it('should allow config in js', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<div class="toast">',
' <div class="toast-body">a simple toast</div>',
'</div>'
].join('')
const toastEl = fixtureEl.querySelector('div')!
const toast = new Toast(toastEl, { delay: 1 })
toastEl.addEventListener('shown.bs.toast', () => {
expect(toastEl.classList.contains('show')).toBe(true)
resolve()
})
toast.show()
})
})
it('should close toast when dismiss button is clicked', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<div class="toast" data-bs-delay="1" data-bs-autohide="false" data-bs-animation="false">',
' <button type="button" class="btn-close" data-bs-dismiss="toast"></button>',
'</div>'
].join('')
const toastEl = fixtureEl.querySelector('div')!
const toast = new Toast(toastEl)
toastEl.addEventListener('shown.bs.toast', () => {
expect(toastEl.classList.contains('show')).toBe(true)
const button = toastEl.querySelector('.btn-close') as HTMLElement
button.click()
})
toastEl.addEventListener('hidden.bs.toast', () => {
expect(toastEl.classList.contains('show')).toBe(false)
resolve()
})
toast.show()
})
})
it('should close toast via data-tblr-dismiss', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<div class="toast" data-bs-delay="1" data-bs-autohide="false" data-bs-animation="false">',
' <button type="button" class="btn-close" data-tblr-dismiss="toast"></button>',
'</div>'
].join('')
const toastEl = fixtureEl.querySelector('div')!
const toast = new Toast(toastEl)
toastEl.addEventListener('shown.bs.toast', () => {
expect(toastEl.classList.contains('show')).toBe(true)
const button = toastEl.querySelector('.btn-close') as HTMLElement
button.click()
})
toastEl.addEventListener('hidden.bs.toast', () => {
expect(toastEl.classList.contains('show')).toBe(false)
resolve()
})
toast.show()
})
})
})
describe('Default', () => {
it('should expose default settings', () => {
const defaultDelay = 1000
const origDelay = Toast.Default.delay
Toast.Default.delay = defaultDelay
fixtureEl.innerHTML = [
'<div class="toast" data-bs-autohide="false" data-bs-animation="false">',
' <button type="button" class="btn-close" data-bs-dismiss="toast"></button>',
'</div>'
].join('')
const toastEl = fixtureEl.querySelector('div')!
const toast = new Toast(toastEl)
expect(toast._config.delay).toBe(defaultDelay)
Toast.Default.delay = origDelay
})
})
describe('DefaultType', () => {
it('should expose default setting types', () => {
expect(typeof Toast.DefaultType).toBe('object')
})
})
describe('show', () => {
it('should auto hide', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<div class="toast" data-bs-delay="1">',
' <div class="toast-body">a simple toast</div>',
'</div>'
].join('')
const toastEl = fixtureEl.querySelector('.toast')!
const toast = new Toast(toastEl)
toastEl.addEventListener('hidden.bs.toast', () => {
expect(toastEl.classList.contains('show')).toBe(false)
resolve()
})
toast.show()
})
})
it('should not add fade class when animation is false', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<div class="toast" data-bs-delay="1" data-bs-animation="false">',
' <div class="toast-body">a simple toast</div>',
'</div>'
].join('')
const toastEl = fixtureEl.querySelector('.toast')!
const toast = new Toast(toastEl)
toastEl.addEventListener('shown.bs.toast', () => {
expect(toastEl.classList.contains('fade')).toBe(false)
resolve()
})
toast.show()
})
})
it('should not trigger shown if show is prevented', () => {
return new Promise<void>((resolve, reject) => {
fixtureEl.innerHTML = [
'<div class="toast" data-bs-delay="1" data-bs-animation="false">',
' <div class="toast-body">a simple toast</div>',
'</div>'
].join('')
const toastEl = fixtureEl.querySelector('.toast')!
const toast = new Toast(toastEl)
toastEl.addEventListener('show.bs.toast', event => {
event.preventDefault()
setTimeout(() => {
expect(toastEl.classList.contains('show')).toBe(false)
resolve()
}, 20)
})
toastEl.addEventListener('shown.bs.toast', () => {
reject(new Error('shown should not fire'))
})
toast.show()
})
})
it('should clear timeout on mouse interaction', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<div class="toast">',
' <div class="toast-body">a simple toast</div>',
'</div>'
].join('')
const toastEl = fixtureEl.querySelector('.toast')!
const toast = new Toast(toastEl)
toastEl.addEventListener('shown.bs.toast', () => {
const clearSpy = vi.spyOn(toast, '_clearTimeout' as any)
toastEl.dispatchEvent(createEvent('mouseover'))
setTimeout(() => {
expect(clearSpy).toHaveBeenCalled()
expect(toast._timeout).toBeNull()
resolve()
}, 10)
})
toast.show()
})
})
it('should clear timeout on keyboard interaction', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<button id="outside">outside</button>',
'<div class="toast">',
' <div class="toast-body">a simple toast <button>inside</button></div>',
'</div>'
].join('')
const toastEl = fixtureEl.querySelector('.toast')!
const toast = new Toast(toastEl)
toastEl.addEventListener('shown.bs.toast', () => {
const clearSpy = vi.spyOn(toast, '_clearTimeout' as any)
const insideBtn = toastEl.querySelector('button')!
insideBtn.focus()
setTimeout(() => {
expect(clearSpy).toHaveBeenCalled()
expect(toast._timeout).toBeNull()
resolve()
}, 10)
})
toast.show()
})
})
it('should still auto hide after mouse and keyboard leave', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<button id="outside-focusable">outside</button>',
'<div class="toast">',
' <div class="toast-body">a simple toast <button>inside</button></div>',
'</div>'
].join('')
const toastEl = fixtureEl.querySelector('.toast')!
const toast = new Toast(toastEl)
toastEl.addEventListener('shown.bs.toast', () => {
toastEl.dispatchEvent(createEvent('mouseover'))
const insideBtn = toastEl.querySelector('button')!
insideBtn.focus()
const mouseOutEvent = new MouseEvent('mouseout', { bubbles: true, relatedTarget: document.querySelector('#outside-focusable') })
toastEl.dispatchEvent(mouseOutEvent)
const outsideFocusable = document.querySelector('#outside-focusable') as HTMLElement
outsideFocusable.focus()
setTimeout(() => {
expect(toast._timeout).not.toBeNull()
resolve()
}, 10)
})
toast.show()
})
})
it('should not auto hide if focus leaves but mouse remains inside', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<button id="outside-focusable">outside</button>',
'<div class="toast">',
' <div class="toast-body">a simple toast <button>inside</button></div>',
'</div>'
].join('')
const toastEl = fixtureEl.querySelector('.toast')!
const toast = new Toast(toastEl)
toastEl.addEventListener('shown.bs.toast', () => {
toastEl.dispatchEvent(createEvent('mouseover'))
const insideBtn = toastEl.querySelector('button')!
insideBtn.focus()
const outsideFocusable = document.querySelector('#outside-focusable') as HTMLElement
outsideFocusable.focus()
setTimeout(() => {
expect(toast._timeout).toBeNull()
resolve()
}, 10)
})
toast.show()
})
})
it('should not auto hide if mouse leaves but focus remains inside', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<button id="outside-focusable">outside</button>',
'<div class="toast">',
' <div class="toast-body">a simple toast <button>inside</button></div>',
'</div>'
].join('')
const toastEl = fixtureEl.querySelector('.toast')!
const toast = new Toast(toastEl)
toastEl.addEventListener('shown.bs.toast', () => {
toastEl.dispatchEvent(createEvent('mouseover'))
const insideBtn = toastEl.querySelector('button')!
insideBtn.focus()
const mouseOutEvent = new MouseEvent('mouseout', { bubbles: true, relatedTarget: document.querySelector('#outside-focusable') })
toastEl.dispatchEvent(mouseOutEvent)
setTimeout(() => {
expect(toast._timeout).toBeNull()
resolve()
}, 10)
})
toast.show()
})
})
it('should handle _onInteraction with unknown event type', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<div class="toast">',
' <div class="toast-body">a simple toast</div>',
'</div>'
].join('')
const toastEl = fixtureEl.querySelector('.toast')!
const toast = new Toast(toastEl)
toastEl.addEventListener('shown.bs.toast', () => {
const scheduleSpy = vi.spyOn(toast, '_maybeScheduleHide' as any)
toast._onInteraction(createEvent('click'), false)
expect(toast._hasMouseInteraction).toBe(false)
expect(toast._hasKeyboardInteraction).toBe(false)
expect(scheduleSpy).toHaveBeenCalled()
resolve()
})
toast.show()
})
})
it('should not schedule hide when relatedTarget is within the toast', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<button id="outside-focusable">outside</button>',
'<div class="toast">',
' <div class="toast-body">a simple toast <button id="inside-btn">inside</button></div>',
'</div>'
].join('')
const toastEl = fixtureEl.querySelector('.toast')!
const insideBtn = toastEl.querySelector('#inside-btn')!
const toast = new Toast(toastEl)
toastEl.addEventListener('shown.bs.toast', () => {
const scheduleSpy = vi.spyOn(toast, '_maybeScheduleHide' as any)
const focusOutEvent = new FocusEvent('focusout', {
bubbles: true,
relatedTarget: insideBtn
})
toast._onInteraction(focusOutEvent, false)
expect(scheduleSpy).not.toHaveBeenCalled()
resolve()
})
toast.show()
})
})
})
describe('hide', () => {
it('should allow to hide toast manually', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<div class="toast" data-bs-delay="1" data-bs-autohide="false">',
' <div class="toast-body">a simple toast</div>',
'</div>'
].join('')
const toastEl = fixtureEl.querySelector('.toast')!
const toast = new Toast(toastEl)
toastEl.addEventListener('shown.bs.toast', () => {
toast.hide()
})
toastEl.addEventListener('hidden.bs.toast', () => {
expect(toastEl.classList.contains('show')).toBe(false)
resolve()
})
toast.show()
})
})
it('should do nothing on a non shown toast', () => {
fixtureEl.innerHTML = '<div></div>'
const toastEl = fixtureEl.querySelector('div')!
const toast = new Toast(toastEl)
const spy = vi.spyOn(toastEl.classList, 'contains')
toast.hide()
expect(spy).toHaveBeenCalled()
})
it('should not trigger hidden if hide is prevented', () => {
return new Promise<void>((resolve, reject) => {
fixtureEl.innerHTML = [
'<div class="toast" data-bs-delay="1" data-bs-animation="false">',
' <div class="toast-body">a simple toast</div>',
'</div>'
].join('')
const toastEl = fixtureEl.querySelector('.toast')!
const toast = new Toast(toastEl)
toastEl.addEventListener('shown.bs.toast', () => {
toast.hide()
})
toastEl.addEventListener('hide.bs.toast', event => {
event.preventDefault()
setTimeout(() => {
expect(toastEl.classList.contains('show')).toBe(true)
resolve()
}, 20)
})
toastEl.addEventListener('hidden.bs.toast', () => {
reject(new Error('hidden should not fire'))
})
toast.show()
})
})
})
describe('dispose', () => {
it('should allow to destroy toast', () => {
fixtureEl.innerHTML = '<div></div>'
const toastEl = fixtureEl.querySelector('div')!
const toast = new Toast(toastEl)
expect(Toast.getInstance(toastEl)).not.toBeNull()
toast.dispose()
expect(Toast.getInstance(toastEl)).toBeNull()
})
it('should destroy and hide shown toast', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = [
'<div class="toast" data-bs-delay="0" data-bs-autohide="false">',
' <div class="toast-body">a simple toast</div>',
'</div>'
].join('')
const toastEl = fixtureEl.querySelector('div')!
const toast = new Toast(toastEl)
toastEl.addEventListener('shown.bs.toast', () => {
setTimeout(() => {
expect(toastEl.classList.contains('show')).toBe(true)
expect(Toast.getInstance(toastEl)).not.toBeNull()
toast.dispose()
expect(Toast.getInstance(toastEl)).toBeNull()
expect(toastEl.classList.contains('show')).toBe(false)
resolve()
}, 1)
})
toast.show()
})
})
})
describe('getInstance', () => {
it('should return toast instance', () => {
fixtureEl.innerHTML = '<div></div>'
const div = fixtureEl.querySelector('div')!
const toast = new Toast(div)
expect(Toast.getInstance(div)).toBe(toast)
expect(Toast.getInstance(div)).toBeInstanceOf(Toast)
})
it('should return null when there is no toast instance', () => {
fixtureEl.innerHTML = '<div></div>'
const div = fixtureEl.querySelector('div')!
expect(Toast.getInstance(div)).toBeNull()
})
})
describe('getOrCreateInstance', () => {
it('should return toast instance', () => {
fixtureEl.innerHTML = '<div></div>'
const div = fixtureEl.querySelector('div')!
const toast = new Toast(div)
expect(Toast.getOrCreateInstance(div)).toBe(toast)
expect(Toast.getOrCreateInstance(div)).toBeInstanceOf(Toast)
})
it('should return new instance when there is no toast instance', () => {
fixtureEl.innerHTML = '<div></div>'
const div = fixtureEl.querySelector('div')!
expect(Toast.getInstance(div)).toBeNull()
expect(Toast.getOrCreateInstance(div)).toBeInstanceOf(Toast)
})
it('should return new instance with given configuration', () => {
fixtureEl.innerHTML = '<div></div>'
const div = fixtureEl.querySelector('div')!
const toast = Toast.getOrCreateInstance(div, { delay: 1 })
expect(toast).toBeInstanceOf(Toast)
expect(toast._config.delay).toBe(1)
})
it('should return existing instance ignoring new configuration', () => {
fixtureEl.innerHTML = '<div></div>'
const div = fixtureEl.querySelector('div')!
const toast = new Toast(div, { delay: 1 })
const toast2 = Toast.getOrCreateInstance(div, { delay: 2 })
expect(toast2).toBe(toast)
expect(toast2._config.delay).toBe(1)
})
})
})

View File

@@ -0,0 +1,679 @@
import { describe, it, expect, beforeAll, afterEach, vi } from 'vitest'
import Tooltip from '../../src/bootstrap/tooltip'
import { clearFixture, getFixture } from '../helpers/fixture'
vi.mock('@popperjs/core', () => ({
createPopper: vi.fn(() => ({
destroy: vi.fn(),
update: vi.fn(),
setOptions: vi.fn()
}))
}))
describe('Tooltip', () => {
let fixtureEl: HTMLElement
beforeAll(() => {
fixtureEl = getFixture()
})
afterEach(() => {
clearFixture()
vi.restoreAllMocks()
for (const el of document.querySelectorAll('.tooltip')) {
el.remove()
}
})
describe('VERSION', () => {
it('should return plugin version', () => {
expect(typeof Tooltip.VERSION).toBe('string')
})
})
describe('Default', () => {
it('should return plugin default config', () => {
expect(Tooltip.Default).toBeDefined()
expect(Tooltip.Default.animation).toBe(true)
expect(Tooltip.Default.trigger).toBe('hover focus')
})
})
describe('DefaultType', () => {
it('should return plugin default type config', () => {
expect(Tooltip.DefaultType).toBeDefined()
expect(Tooltip.DefaultType.animation).toBe('boolean')
})
})
describe('NAME', () => {
it('should return plugin name', () => {
expect(Tooltip.NAME).toBe('tooltip')
})
})
describe('constructor', () => {
it('should create tooltip instance', () => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip title">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el)
expect(tooltip).toBeInstanceOf(Tooltip)
expect(Tooltip.getInstance(el)).toBe(tooltip)
})
it('should fix title on construction', () => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip title">Trigger</a>'
const el = fixtureEl.querySelector('a')!
new Tooltip(el)
expect(el.getAttribute('title')).toBeNull()
expect(el.getAttribute('data-bs-original-title')).toBe('Tooltip title')
})
it('should set aria-label when element has title but no text', () => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip title"></a>'
const el = fixtureEl.querySelector('a')!
new Tooltip(el)
expect(el.getAttribute('aria-label')).toBe('Tooltip title')
})
it('should not set aria-label when element has text content', () => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip title">Link text</a>'
const el = fixtureEl.querySelector('a')!
new Tooltip(el)
expect(el.getAttribute('aria-label')).toBeNull()
})
it('should not set aria-label when element already has aria-label', () => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip title" aria-label="Existing label"></a>'
const el = fixtureEl.querySelector('a')!
new Tooltip(el)
expect(el.getAttribute('aria-label')).toBe('Existing label')
})
})
describe('enable', () => {
it('should enable tooltip', () => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el)
tooltip.disable()
tooltip.enable()
expect(tooltip._isEnabled).toBe(true)
})
})
describe('disable', () => {
it('should disable tooltip', () => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el)
tooltip.disable()
expect(tooltip._isEnabled).toBe(false)
})
})
describe('toggleEnabled', () => {
it('should toggle enabled state', () => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el)
expect(tooltip._isEnabled).toBe(true)
tooltip.toggleEnabled()
expect(tooltip._isEnabled).toBe(false)
tooltip.toggleEnabled()
expect(tooltip._isEnabled).toBe(true)
})
})
describe('toggle', () => {
it('should do nothing when disabled', () => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el)
tooltip.disable()
tooltip.toggle()
expect(tooltip._isShown()).toBe(false)
})
it('should toggle tooltip visibility', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip title">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el, { animation: false })
el.addEventListener('shown.bs.tooltip', () => {
expect(tooltip._isShown()).toBe(true)
tooltip.toggle()
})
el.addEventListener('hidden.bs.tooltip', () => {
expect(tooltip._isShown()).toBe(false)
resolve()
})
tooltip.toggle()
})
})
})
describe('show', () => {
it('should show tooltip', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip title">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el, { animation: false })
el.addEventListener('shown.bs.tooltip', () => {
expect(tooltip.tip).not.toBeNull()
expect(tooltip.tip!.classList.contains('show')).toBe(true)
expect(el.getAttribute('aria-describedby')).not.toBeNull()
resolve()
})
tooltip.show()
})
})
it('should throw if element is hidden', () => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip" style="display: none">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el)
expect(() => tooltip.show()).toThrow('Please use show on visible elements')
})
it('should not show if show event is prevented', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip title">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el, { animation: false })
el.addEventListener('show.bs.tooltip', event => {
event.preventDefault()
setTimeout(() => {
expect(tooltip._isShown()).toBe(false)
resolve()
}, 30)
})
tooltip.show()
})
})
it('should not show without content', () => {
fixtureEl.innerHTML = '<a href="#">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el)
tooltip.show()
expect(tooltip._isShown()).toBe(false)
})
it('should fire inserted event', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip title">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el, { animation: false })
el.addEventListener('inserted.bs.tooltip', () => {
resolve()
})
tooltip.show()
})
})
})
describe('hide', () => {
it('should hide tooltip', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip title">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el, { animation: false })
el.addEventListener('shown.bs.tooltip', () => {
tooltip.hide()
})
el.addEventListener('hidden.bs.tooltip', () => {
expect(tooltip._isShown()).toBe(false)
resolve()
})
tooltip.show()
})
})
it('should not hide if not shown', () => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el)
tooltip.hide()
expect(tooltip._isShown()).toBe(false)
})
it('should not hide if hide event is prevented', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip title">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el, { animation: false })
el.addEventListener('shown.bs.tooltip', () => {
el.addEventListener('hide.bs.tooltip', event => {
event.preventDefault()
setTimeout(() => {
expect(tooltip._isShown()).toBe(true)
resolve()
}, 30)
})
tooltip.hide()
})
tooltip.show()
})
})
})
describe('update', () => {
it('should call popper update', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip title">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el, { animation: false })
el.addEventListener('shown.bs.tooltip', () => {
tooltip.update()
expect(tooltip._popper!.update).toHaveBeenCalled()
resolve()
})
tooltip.show()
})
})
it('should do nothing if popper is null', () => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el)
expect(() => tooltip.update()).not.toThrow()
})
})
describe('dispose', () => {
it('should dispose tooltip', () => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip title">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el)
tooltip.dispose()
expect(Tooltip.getInstance(el)).toBeNull()
})
it('should restore original title on dispose', () => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip title">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el)
expect(el.getAttribute('data-bs-original-title')).toBe('Tooltip title')
tooltip.dispose()
expect(el.getAttribute('title')).toBe('Tooltip title')
})
it('should destroy popper on dispose', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip title">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el, { animation: false })
el.addEventListener('shown.bs.tooltip', () => {
const destroySpy = tooltip._popper!.destroy
tooltip.dispose()
expect(destroySpy).toHaveBeenCalled()
resolve()
})
tooltip.show()
})
})
})
describe('setContent', () => {
it('should set new content', () => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip title">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el)
tooltip.setContent({ '.tooltip-inner': 'New content' })
expect(tooltip._newContent).toEqual({ '.tooltip-inner': 'New content' })
})
it('should update shown tooltip', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip title">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el, { animation: false })
let shownCount = 0
el.addEventListener('shown.bs.tooltip', () => {
shownCount++
if (shownCount === 1) {
tooltip.setContent({ '.tooltip-inner': 'Updated content' })
} else {
resolve()
}
})
tooltip.show()
})
})
})
describe('_isWithContent', () => {
it('should return true when title exists', () => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip title">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el)
expect(tooltip._isWithContent()).toBe(true)
})
it('should return false when no title', () => {
fixtureEl.innerHTML = '<a href="#">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el)
expect(tooltip._isWithContent()).toBe(false)
})
})
describe('_getTitle', () => {
it('should return config title', () => {
fixtureEl.innerHTML = '<a href="#">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el, { title: 'Config title' })
expect(tooltip._getTitle()).toBe('Config title')
})
it('should return data-bs-original-title', () => {
fixtureEl.innerHTML = '<a href="#" data-bs-original-title="Original title">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el)
expect(tooltip._getTitle()).toBe('Original title')
})
it('should return data-tblr-original-title', () => {
fixtureEl.innerHTML = '<a href="#" data-tblr-original-title="Tblr title">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el)
expect(tooltip._getTitle()).toBe('Tblr title')
})
it('should prefer data-bs-original-title over data-tblr-original-title', () => {
fixtureEl.innerHTML = '<a href="#" data-bs-original-title="BS title" data-tblr-original-title="Tblr title">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el)
expect(tooltip._getTitle()).toBe('BS title')
})
it('should resolve function title', () => {
fixtureEl.innerHTML = '<a href="#">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el, { title: () => 'Function title' })
expect(tooltip._getTitle()).toBe('Function title')
})
})
describe('_isAnimated', () => {
it('should return true when animation is enabled', () => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el, { animation: true })
expect(tooltip._isAnimated()).toBe(true)
})
it('should return false when animation is disabled', () => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el, { animation: false })
expect(tooltip._isAnimated()).toBe(false)
})
})
describe('_configAfterMerge', () => {
it('should convert number delay to object', () => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el, { delay: 200 })
expect(tooltip._config.delay).toEqual({ show: 200, hide: 200 })
})
it('should convert number title to string', () => {
fixtureEl.innerHTML = '<a href="#">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el, { title: 123 })
expect(tooltip._config.title).toBe('123')
})
it('should use document.body when container is false', () => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el)
expect(tooltip._config.container).toBe(document.body)
})
})
describe('_getOffset', () => {
it('should handle string offset', () => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el, { offset: '10,20' as any })
expect(tooltip._getOffset()).toEqual([10, 20])
})
it('should handle function offset', () => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const offsetFn = vi.fn().mockReturnValue([5, 10])
const tooltip = new Tooltip(el, { offset: offsetFn })
const offset = tooltip._getOffset()
expect(typeof offset).toBe('function')
})
it('should handle array offset', () => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el, { offset: [5, 15] })
expect(tooltip._getOffset()).toEqual([5, 15])
})
})
describe('_getDelegateConfig', () => {
it('should return config with non-default values', () => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el, { placement: 'bottom' })
const delegateConfig = tooltip._getDelegateConfig()
expect(delegateConfig.selector).toBe(false)
expect(delegateConfig.trigger).toBe('manual')
expect(delegateConfig.placement).toBe('bottom')
})
})
describe('_disposePopper', () => {
it('should destroy popper and remove tip', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip title">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el, { animation: false })
el.addEventListener('shown.bs.tooltip', () => {
expect(tooltip._popper).not.toBeNull()
expect(tooltip.tip).not.toBeNull()
tooltip._disposePopper()
expect(tooltip._popper).toBeNull()
expect(tooltip.tip).toBeNull()
resolve()
})
tooltip.show()
})
})
})
describe('getInstance', () => {
it('should return null if no instance', () => {
expect(Tooltip.getInstance(fixtureEl)).toBeNull()
})
it('should return tooltip instance', () => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el)
expect(Tooltip.getInstance(el)).toBe(tooltip)
})
})
describe('getOrCreateInstance', () => {
it('should return existing instance', () => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el)
expect(Tooltip.getOrCreateInstance(el)).toBe(tooltip)
})
it('should create new instance', () => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip">Trigger</a>'
const el = fixtureEl.querySelector('a')!
expect(Tooltip.getInstance(el)).toBeNull()
expect(Tooltip.getOrCreateInstance(el)).toBeInstanceOf(Tooltip)
})
})
describe('data-tblr-original-title', () => {
it('should restore title from data-tblr-original-title on dispose', () => {
fixtureEl.innerHTML = '<a href="#" data-tblr-original-title="Tblr tooltip">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el, { title: 'test' })
tooltip.dispose()
expect(el.getAttribute('title')).toBe('Tblr tooltip')
})
it('should use data-tblr-original-title as fallback for _getTitle', () => {
fixtureEl.innerHTML = '<a href="#" data-tblr-original-title="Tblr tooltip title">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el)
expect(tooltip._getTitle()).toBe('Tblr tooltip title')
})
})
describe('_enter and _leave', () => {
it('should set _isHovered on enter', () => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el, { delay: { show: 500, hide: 500 } })
tooltip._enter()
expect(tooltip._isHovered).toBe(true)
})
it('should not double-enter if already hovered', () => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el, { delay: { show: 500, hide: 500 } })
tooltip._isHovered = true
tooltip._enter()
expect(tooltip._isHovered).toBe(true)
})
it('should set _isHovered to false on leave', () => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el, { delay: { show: 500, hide: 500 } })
tooltip._leave()
expect(tooltip._isHovered).toBe(false)
})
it('should not leave if active trigger exists', () => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el)
tooltip._activeTrigger.click = true
tooltip._isHovered = true
tooltip._leave()
expect(tooltip._isHovered).toBe(true)
})
})
describe('_isWithActiveTrigger', () => {
it('should return false with no active triggers', () => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el)
expect(tooltip._isWithActiveTrigger()).toBe(false)
})
it('should return true with active trigger', () => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el)
tooltip._activeTrigger.click = true
expect(tooltip._isWithActiveTrigger()).toBe(true)
})
})
describe('trigger listeners', () => {
it('should set up click trigger', () => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el, { trigger: 'click', animation: false })
expect(tooltip._config.trigger).toBe('click')
})
it('should set up manual trigger', () => {
fixtureEl.innerHTML = '<a href="#" title="Tooltip">Trigger</a>'
const el = fixtureEl.querySelector('a')!
const tooltip = new Tooltip(el, { trigger: 'manual' })
expect(tooltip._config.trigger).toBe('manual')
})
})
})

View File

@@ -0,0 +1,211 @@
import { describe, it, expect, beforeAll, afterEach } from 'vitest'
import Backdrop from '../../../src/bootstrap/util/backdrop'
import { clearFixture, getFixture } from '../../helpers/fixture'
const CLASS_BACKDROP = '.modal-backdrop'
const CLASS_NAME_FADE = 'fade'
const CLASS_NAME_SHOW = 'show'
describe('Backdrop', () => {
let fixtureEl: HTMLElement
beforeAll(() => {
fixtureEl = getFixture()
})
afterEach(() => {
clearFixture()
for (const el of document.querySelectorAll(CLASS_BACKDROP)) {
el.remove()
}
})
describe('show', () => {
it('should append the backdrop and include the "show" class', () => {
return new Promise<void>(resolve => {
const instance = new Backdrop({ isVisible: true, isAnimated: false })
const getElements = () => document.querySelectorAll(CLASS_BACKDROP)
expect(getElements()).toHaveLength(0)
instance.show()
instance.show(() => {
expect(getElements()).toHaveLength(1)
for (const el of getElements()) {
expect(el.classList.contains(CLASS_NAME_SHOW)).toBe(true)
}
resolve()
})
})
})
it('should not append the backdrop if not visible', () => {
return new Promise<void>(resolve => {
const instance = new Backdrop({ isVisible: false, isAnimated: true })
const getElements = () => document.querySelectorAll(CLASS_BACKDROP)
expect(getElements()).toHaveLength(0)
instance.show(() => {
expect(getElements()).toHaveLength(0)
resolve()
})
})
})
it('should include the "fade" class if animated', () => {
return new Promise<void>(resolve => {
const instance = new Backdrop({ isVisible: true, isAnimated: true })
const getElements = () => document.querySelectorAll(CLASS_BACKDROP)
expect(getElements()).toHaveLength(0)
instance.show(() => {
expect(getElements()).toHaveLength(1)
for (const el of getElements()) {
expect(el.classList.contains(CLASS_NAME_FADE)).toBe(true)
}
resolve()
})
})
})
})
describe('hide', () => {
it('should remove the backdrop html', () => {
return new Promise<void>(resolve => {
const instance = new Backdrop({ isVisible: true, isAnimated: true })
const getElements = () => document.body.querySelectorAll(CLASS_BACKDROP)
expect(getElements()).toHaveLength(0)
instance.show(() => {
expect(getElements()).toHaveLength(1)
instance.hide(() => {
expect(getElements()).toHaveLength(0)
resolve()
})
})
})
})
it('should remove the "show" class', () => {
return new Promise<void>(resolve => {
const instance = new Backdrop({ isVisible: true, isAnimated: true })
const elem = instance._getElement()
instance.show()
instance.hide(() => {
expect(elem.classList.contains(CLASS_NAME_SHOW)).toBe(false)
resolve()
})
})
})
it('should not try to remove if not visible', () => {
return new Promise<void>(resolve => {
const instance = new Backdrop({ isVisible: false, isAnimated: true })
const getElements = () => document.querySelectorAll(CLASS_BACKDROP)
expect(getElements()).toHaveLength(0)
expect(instance._isAppended).toBe(false)
instance.show(() => {
instance.hide(() => {
expect(getElements()).toHaveLength(0)
expect(instance._isAppended).toBe(false)
resolve()
})
})
})
})
})
describe('click callback', () => {
it('should execute callback on click', () => {
return new Promise<void>(resolve => {
let called = false
const instance = new Backdrop({
isVisible: true,
isAnimated: false,
clickCallback: () => { called = true }
})
instance.show(() => {
const clickEvent = new Event('mousedown', { bubbles: true, cancelable: true })
document.querySelector(CLASS_BACKDROP)!.dispatchEvent(clickEvent)
setTimeout(() => {
expect(called).toBe(true)
resolve()
}, 10)
})
})
})
})
describe('Config', () => {
it('should be appended on document.body by default', () => {
return new Promise<void>(resolve => {
const instance = new Backdrop({ isVisible: true })
instance.show(() => {
expect(document.querySelector(CLASS_BACKDROP)!.parentElement).toBe(document.body)
resolve()
})
})
})
it('should find rootElement if passed as a string', () => {
return new Promise<void>(resolve => {
const instance = new Backdrop({ isVisible: true, rootElement: 'body' })
instance.show(() => {
expect(document.querySelector(CLASS_BACKDROP)!.parentElement).toBe(document.body)
resolve()
})
})
})
it('should be appended on custom rootElement', () => {
return new Promise<void>(resolve => {
fixtureEl.innerHTML = '<div id="wrapper"></div>'
const wrapper = fixtureEl.querySelector('#wrapper')!
const instance = new Backdrop({ isVisible: true, rootElement: wrapper })
instance.show(() => {
expect(document.querySelector(CLASS_BACKDROP)!.parentElement).toBe(wrapper)
resolve()
})
})
})
it('should allow configuring className', () => {
return new Promise<void>(resolve => {
const instance = new Backdrop({ isVisible: true, className: 'foo' })
instance.show(() => {
expect(document.querySelector('.foo')).toBe(instance._getElement())
instance.dispose()
resolve()
})
})
})
})
describe('dispose', () => {
it('should do nothing if not appended', () => {
const instance = new Backdrop({ isVisible: true, isAnimated: false })
expect(instance._isAppended).toBe(false)
instance.dispose()
expect(instance._isAppended).toBe(false)
})
it('should remove element and reset _isAppended after show', () => {
return new Promise<void>(resolve => {
const instance = new Backdrop({ isVisible: true, isAnimated: false })
instance.show(() => {
expect(instance._isAppended).toBe(true)
instance.dispose()
expect(instance._isAppended).toBe(false)
expect(document.querySelectorAll(CLASS_BACKDROP)).toHaveLength(0)
resolve()
})
})
})
})
})

View File

@@ -0,0 +1,104 @@
import { describe, it, expect, beforeAll, afterEach, vi } from 'vitest'
import BaseComponent from '../../../src/bootstrap/base-component'
import { enableDismissTrigger } from '../../../src/bootstrap/util/component-functions'
import { clearFixture, createEvent, getFixture } from '../../helpers/fixture'
class DummyClass extends BaseComponent {
static get NAME(): string {
return 'test'
}
hide() {
return true
}
testMethod() {
return true
}
}
describe('Component Functions', () => {
let fixtureEl: HTMLElement
beforeAll(() => {
fixtureEl = getFixture()
})
afterEach(() => {
clearFixture()
})
describe('data-bs-dismiss', () => {
it('should get plugin and execute given method on click', () => {
fixtureEl.innerHTML = [
'<div id="foo" class="test">',
' <button type="button" data-bs-dismiss="test" data-bs-target="#foo"></button>',
'</div>'
].join('')
const spyGet = vi.spyOn(DummyClass, 'getOrCreateInstance')
const spyTest = vi.spyOn(DummyClass.prototype, 'testMethod')
enableDismissTrigger(DummyClass, 'testMethod')
fixtureEl.querySelector('[data-bs-dismiss="test"]')!.dispatchEvent(createEvent('click'))
expect(spyGet).toHaveBeenCalled()
expect(spyTest).toHaveBeenCalled()
vi.restoreAllMocks()
})
it('should use closest class when no data-bs-target', () => {
fixtureEl.innerHTML = [
'<div id="foo" class="test">',
' <button type="button" data-bs-dismiss="test"></button>',
'</div>'
].join('')
const spyGet = vi.spyOn(DummyClass, 'getOrCreateInstance')
const spyHide = vi.spyOn(DummyClass.prototype, 'hide')
enableDismissTrigger(DummyClass)
fixtureEl.querySelector('[data-bs-dismiss="test"]')!.dispatchEvent(createEvent('click'))
expect(spyGet).toHaveBeenCalled()
expect(spyHide).toHaveBeenCalled()
vi.restoreAllMocks()
})
it('should not trigger if disabled', () => {
fixtureEl.innerHTML = [
'<div id="foo" class="test">',
' <button type="button" disabled data-bs-dismiss="test"></button>',
'</div>'
].join('')
const spy = vi.spyOn(DummyClass, 'getOrCreateInstance')
enableDismissTrigger(DummyClass)
fixtureEl.querySelector('[data-bs-dismiss="test"]')!.dispatchEvent(createEvent('click'))
expect(spy).not.toHaveBeenCalled()
vi.restoreAllMocks()
})
it('should preventDefault for <a> elements', () => {
fixtureEl.innerHTML = [
'<div id="foo" class="test">',
' <a type="button" data-bs-dismiss="test"></a>',
'</div>'
].join('')
enableDismissTrigger(DummyClass)
const preventSpy = vi.spyOn(Event.prototype, 'preventDefault')
fixtureEl.querySelector('[data-bs-dismiss="test"]')!.dispatchEvent(createEvent('click'))
expect(preventSpy).toHaveBeenCalled()
vi.restoreAllMocks()
})
})
})

View File

@@ -0,0 +1,147 @@
import { describe, it, expect, beforeAll, afterEach, vi } from 'vitest'
import Config from '../../../src/bootstrap/util/config'
import { clearFixture, getFixture } from '../../helpers/fixture'
class DummyConfigClass extends Config {
static get NAME(): string {
return 'dummy'
}
}
describe('Config', () => {
let fixtureEl: HTMLElement
beforeAll(() => {
fixtureEl = getFixture()
})
afterEach(() => {
clearFixture()
})
describe('NAME', () => {
it('should return plugin NAME', () => {
expect(DummyConfigClass.NAME).toBe('dummy')
})
})
describe('Default', () => {
it('should return plugin defaults', () => {
expect(typeof DummyConfigClass.Default).toBe('object')
})
})
describe('DefaultType', () => {
it('should return plugin default type', () => {
expect(typeof DummyConfigClass.DefaultType).toBe('object')
})
})
describe('mergeConfigObj', () => {
it('should parse data attributes and merge with defaults, data attributes excel defaults', () => {
fixtureEl.innerHTML = '<div id="test" data-bs-test-bool="false" data-bs-test-int="8" data-bs-test-string1="bar"></div>'
vi.spyOn(DummyConfigClass, 'Default', 'get').mockReturnValue({
testBool: true,
testString: 'foo',
testString1: 'foo',
testInt: 7
})
const instance = new DummyConfigClass()
const result = instance._mergeConfigObj({}, fixtureEl.querySelector('#test')!)
expect(result.testBool).toBe(false)
expect(result.testString).toBe('foo')
expect(result.testString1).toBe('bar')
expect(result.testInt).toBe(8)
vi.restoreAllMocks()
})
it('should let programmatic config excel data attributes', () => {
fixtureEl.innerHTML = '<div id="test" data-bs-test-bool="false" data-bs-test-int="8" data-bs-test-string-1="bar"></div>'
vi.spyOn(DummyConfigClass, 'Default', 'get').mockReturnValue({
testBool: true,
testString: 'foo',
testString1: 'foo',
testInt: 7
})
const instance = new DummyConfigClass()
const result = instance._mergeConfigObj({
testString1: 'test',
testInt: 3
}, fixtureEl.querySelector('#test')!)
expect(result.testBool).toBe(false)
expect(result.testString).toBe('foo')
expect(result.testString1).toBe('test')
expect(result.testInt).toBe(3)
vi.restoreAllMocks()
})
it('should omit data-bs-config if it is not an object', () => {
fixtureEl.innerHTML = '<div id="test" data-bs-config="foo" data-bs-test-int="8"></div>'
vi.spyOn(DummyConfigClass, 'Default', 'get').mockReturnValue({
testInt: 7,
testInt2: 79
})
const instance = new DummyConfigClass()
const result = instance._mergeConfigObj({}, fixtureEl.querySelector('#test')!)
expect(result.testInt).toBe(8)
expect(result.testInt2).toBe(79)
vi.restoreAllMocks()
})
})
describe('typeCheckConfig', () => {
it('should throw TypeError for wrong config types', () => {
vi.spyOn(DummyConfigClass, 'DefaultType', 'get').mockReturnValue({
toggle: 'boolean',
parent: '(string|element)'
})
const obj = new DummyConfigClass()
expect(() => {
obj._typeCheckConfig({ toggle: true, parent: 777 })
}).toThrow(TypeError)
vi.restoreAllMocks()
})
it('should accept null when type includes null', () => {
vi.spyOn(DummyConfigClass, 'DefaultType', 'get').mockReturnValue({
toggle: 'boolean',
parent: '(null|element)'
})
const obj = new DummyConfigClass()
expect(() => {
obj._typeCheckConfig({ toggle: true, parent: null })
}).not.toThrow()
vi.restoreAllMocks()
})
it('should accept undefined when type includes undefined', () => {
vi.spyOn(DummyConfigClass, 'DefaultType', 'get').mockReturnValue({
toggle: 'boolean',
parent: '(undefined|element)'
})
const obj = new DummyConfigClass()
expect(() => {
obj._typeCheckConfig({ toggle: true, parent: undefined })
}).not.toThrow()
vi.restoreAllMocks()
})
})
})

View File

@@ -0,0 +1,237 @@
import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest'
import FocusTrap from '../../../src/bootstrap/util/focustrap'
import { clearFixture, getFixture } from '../../helpers/fixture'
vi.mock('../../../src/bootstrap/util/index', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../src/bootstrap/util/index')>()
return {
...actual,
isVisible: () => true
}
})
describe('FocusTrap', () => {
let fixtureEl: HTMLElement
let trapEl: HTMLElement
beforeAll(() => {
fixtureEl = getFixture()
})
beforeEach(() => {
fixtureEl.innerHTML =
'<div id="trap" tabindex="-1">' +
'<button id="btn1">First</button>' +
'<button id="btn2">Second</button>' +
'</div>' +
'<button id="outside">Outside</button>'
trapEl = fixtureEl.querySelector('#trap')!
})
afterEach(() => {
clearFixture()
})
describe('static', () => {
it('NAME should return "focustrap"', () => {
expect(FocusTrap.NAME).toBe('focustrap')
})
it('Default should have correct values', () => {
expect(FocusTrap.Default).toEqual({
autofocus: true,
trapElement: null
})
})
it('DefaultType should have correct values', () => {
expect(FocusTrap.DefaultType).toEqual({
autofocus: 'boolean',
trapElement: 'element'
})
})
})
describe('activate', () => {
it('should focus the trap element when autofocus is true', () => {
const focusSpy = vi.spyOn(trapEl, 'focus')
const trap = new FocusTrap({ trapElement: trapEl })
trap.activate()
expect(focusSpy).toHaveBeenCalledOnce()
trap.deactivate()
})
it('should not focus the trap element when autofocus is false', () => {
const focusSpy = vi.spyOn(trapEl, 'focus')
const trap = new FocusTrap({ trapElement: trapEl, autofocus: false })
trap.activate()
expect(focusSpy).not.toHaveBeenCalled()
trap.deactivate()
})
it('should set _isActive to true', () => {
const trap = new FocusTrap({ trapElement: trapEl })
trap.activate()
expect(trap._isActive).toBe(true)
trap.deactivate()
})
it('should not re-activate if already active', () => {
const focusSpy = vi.spyOn(trapEl, 'focus')
const trap = new FocusTrap({ trapElement: trapEl })
trap.activate()
trap.activate()
expect(focusSpy).toHaveBeenCalledOnce()
trap.deactivate()
})
})
describe('deactivate', () => {
it('should set _isActive to false', () => {
const trap = new FocusTrap({ trapElement: trapEl })
trap.activate()
trap.deactivate()
expect(trap._isActive).toBe(false)
})
it('should not throw if not active', () => {
const trap = new FocusTrap({ trapElement: trapEl })
expect(() => trap.deactivate()).not.toThrow()
})
})
describe('_handleFocusin', () => {
it('should do nothing if focus target is the trap element', () => {
const trap = new FocusTrap({ trapElement: trapEl })
trap.activate()
const btn1 = fixtureEl.querySelector('#btn1')! as HTMLElement
const focusSpy = vi.spyOn(btn1, 'focus')
const event = new FocusEvent('focusin', { relatedTarget: null })
Object.defineProperty(event, 'target', { value: trapEl })
document.dispatchEvent(event)
expect(focusSpy).not.toHaveBeenCalled()
trap.deactivate()
})
it('should do nothing if focus target is inside the trap', () => {
const trap = new FocusTrap({ trapElement: trapEl })
trap.activate()
const btn1 = fixtureEl.querySelector('#btn1')! as HTMLElement
const event = new FocusEvent('focusin', { relatedTarget: null })
Object.defineProperty(event, 'target', { value: btn1 })
document.dispatchEvent(event)
trap.deactivate()
})
it('should focus first focusable child when focus leaves trap (forward)', () => {
const trap = new FocusTrap({ trapElement: trapEl })
trap.activate()
const btn1 = fixtureEl.querySelector('#btn1')! as HTMLElement
const focusSpy = vi.spyOn(btn1, 'focus')
const outside = fixtureEl.querySelector('#outside')! as HTMLElement
const event = new FocusEvent('focusin', { relatedTarget: null })
Object.defineProperty(event, 'target', { value: outside })
document.dispatchEvent(event)
expect(focusSpy).toHaveBeenCalled()
trap.deactivate()
})
it('should focus last focusable child when tabbing backward', () => {
const trap = new FocusTrap({ trapElement: trapEl })
trap.activate()
const btn2 = fixtureEl.querySelector('#btn2')! as HTMLElement
const focusSpy = vi.spyOn(btn2, 'focus')
const outside = fixtureEl.querySelector('#outside')! as HTMLElement
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, bubbles: true }))
const focusEvent = new FocusEvent('focusin', { relatedTarget: null })
Object.defineProperty(focusEvent, 'target', { value: outside })
document.dispatchEvent(focusEvent)
expect(focusSpy).toHaveBeenCalled()
trap.deactivate()
})
it('should focus trap element if no focusable children', () => {
fixtureEl.innerHTML = '<div id="empty-trap" tabindex="-1"></div><button id="out">Out</button>'
const emptyTrap = fixtureEl.querySelector('#empty-trap')! as HTMLElement
const trap = new FocusTrap({ trapElement: emptyTrap })
trap.activate()
const focusSpy = vi.spyOn(emptyTrap, 'focus')
focusSpy.mockClear()
const out = fixtureEl.querySelector('#out')! as HTMLElement
const event = new FocusEvent('focusin', { relatedTarget: null })
Object.defineProperty(event, 'target', { value: out })
document.dispatchEvent(event)
expect(focusSpy).toHaveBeenCalled()
trap.deactivate()
})
it('should do nothing if focus target is document', () => {
const trap = new FocusTrap({ trapElement: trapEl })
trap.activate()
const event = new FocusEvent('focusin', { relatedTarget: null })
Object.defineProperty(event, 'target', { value: document })
document.dispatchEvent(event)
trap.deactivate()
})
})
describe('_handleKeydown', () => {
it('should track forward tab direction', () => {
const trap = new FocusTrap({ trapElement: trapEl })
trap.activate()
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }))
expect(trap._lastTabNavDirection).toBe('forward')
trap.deactivate()
})
it('should track backward tab direction (Shift+Tab)', () => {
const trap = new FocusTrap({ trapElement: trapEl })
trap.activate()
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, bubbles: true }))
expect(trap._lastTabNavDirection).toBe('backward')
trap.deactivate()
})
it('should ignore non-Tab keys', () => {
const trap = new FocusTrap({ trapElement: trapEl })
trap.activate()
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
expect(trap._lastTabNavDirection).toBeNull()
trap.deactivate()
})
})
})

View File

@@ -0,0 +1,495 @@
import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest'
import {
execute,
executeAfterTransition,
findShadowRoot,
getElement,
getNextActiveElement,
getTransitionDurationFromElement,
getUID,
isDisabled,
isElement,
isRTL,
isVisible,
noop,
parseSelector,
reflow,
toType,
triggerTransitionEnd
} from '../../../src/bootstrap/util/index'
import { clearFixture, getFixture } from '../../helpers/fixture'
describe('util/index', () => {
let fixtureEl: HTMLElement
beforeAll(() => {
fixtureEl = getFixture()
})
afterEach(() => {
clearFixture()
})
describe('parseSelector', () => {
it('should return the selector as-is for simple selectors', () => {
expect(parseSelector('.my-class')).toBe('.my-class')
})
it('should escape ID selectors with special characters', () => {
const result = parseSelector('#my.id')
expect(result).toContain('#')
})
it('should use CSS.escape when available', () => {
const original = window.CSS
Object.defineProperty(window, 'CSS', {
value: { escape: vi.fn((s: string) => s) },
configurable: true
})
parseSelector('#test-id')
expect(window.CSS.escape).toHaveBeenCalledWith('test-id')
Object.defineProperty(window, 'CSS', { value: original, configurable: true })
})
})
describe('toType', () => {
it('should return "null" for null', () => {
expect(toType(null)).toBe('null')
})
it('should return "undefined" for undefined', () => {
expect(toType(undefined)).toBe('undefined')
})
it('should return "string" for strings', () => {
expect(toType('hello')).toBe('string')
})
it('should return "number" for numbers', () => {
expect(toType(42)).toBe('number')
})
it('should return "object" for objects', () => {
expect(toType({})).toBe('object')
})
it('should return "array" for arrays', () => {
expect(toType([])).toBe('array')
})
it('should return "boolean" for booleans', () => {
expect(toType(true)).toBe('boolean')
})
it('should return "function" for functions', () => {
expect(toType(() => {})).toBe('function')
})
it('should return "regexp" for regexps', () => {
expect(toType(/test/)).toBe('regexp')
})
})
describe('getUID', () => {
it('should return a string starting with the prefix', () => {
const uid = getUID('test')
expect(uid.startsWith('test')).toBe(true)
})
it('should return unique values', () => {
const a = getUID('uid')
const b = getUID('uid')
expect(a).not.toBe(b)
})
})
describe('getTransitionDurationFromElement', () => {
it('should return 0 for null-like element', () => {
expect(getTransitionDurationFromElement(null as unknown as HTMLElement)).toBe(0)
})
it('should return 0 when no transition is set', () => {
fixtureEl.innerHTML = '<div></div>'
const div = fixtureEl.querySelector('div')!
expect(getTransitionDurationFromElement(div)).toBe(0)
})
it('should return duration + delay in ms', () => {
fixtureEl.innerHTML = '<div></div>'
const div = fixtureEl.querySelector('div')!
vi.spyOn(window, 'getComputedStyle').mockReturnValue({
transitionDuration: '0.3s',
transitionDelay: '0.1s'
} as CSSStyleDeclaration)
expect(getTransitionDurationFromElement(div)).toBe(400)
vi.restoreAllMocks()
})
it('should handle comma-separated values (use first)', () => {
fixtureEl.innerHTML = '<div></div>'
const div = fixtureEl.querySelector('div')!
vi.spyOn(window, 'getComputedStyle').mockReturnValue({
transitionDuration: '0.5s, 0.2s',
transitionDelay: '0.1s, 0s'
} as CSSStyleDeclaration)
expect(getTransitionDurationFromElement(div)).toBe(600)
vi.restoreAllMocks()
})
})
describe('triggerTransitionEnd', () => {
it('should dispatch a transitionend event', () => {
fixtureEl.innerHTML = '<div></div>'
const div = fixtureEl.querySelector('div')!
const handler = vi.fn()
div.addEventListener('transitionend', handler)
triggerTransitionEnd(div)
expect(handler).toHaveBeenCalledOnce()
})
})
describe('isElement', () => {
it('should return true for DOM elements', () => {
expect(isElement(document.createElement('div'))).toBe(true)
})
it('should return false for null', () => {
expect(isElement(null)).toBe(false)
})
it('should return false for strings', () => {
expect(isElement('div')).toBe(false)
})
it('should return false for plain objects', () => {
expect(isElement({})).toBe(false)
})
it('should return false for non-objects', () => {
expect(isElement(42)).toBe(false)
})
})
describe('getElement', () => {
it('should return the element if given an HTMLElement', () => {
const el = document.createElement('div')
expect(getElement(el)).toBe(el)
})
it('should query by string selector', () => {
fixtureEl.innerHTML = '<div id="test-el"></div>'
const el = getElement('#test-el')
expect(el).not.toBeNull()
expect(el!.id).toBe('test-el')
})
it('should return null for empty string', () => {
expect(getElement('')).toBeNull()
})
it('should return null for null', () => {
expect(getElement(null)).toBeNull()
})
it('should return null for numbers', () => {
expect(getElement(42)).toBeNull()
})
})
describe('isVisible', () => {
it('should return false for non-element', () => {
expect(isVisible(null as unknown as HTMLElement)).toBe(false)
})
it('should return false when getClientRects is empty', () => {
fixtureEl.innerHTML = '<div style="display:none"></div>'
const div = fixtureEl.querySelector('div')!
expect(isVisible(div)).toBe(false)
})
it('should return true for visible element', () => {
fixtureEl.innerHTML = '<div style="visibility:visible">text</div>'
const div = fixtureEl.querySelector('div')!
vi.spyOn(div, 'getClientRects').mockReturnValue([{ width: 100, height: 100 }] as unknown as DOMRectList)
expect(isVisible(div)).toBe(true)
})
it('should return false for hidden visibility', () => {
fixtureEl.innerHTML = '<div style="visibility:hidden">text</div>'
const div = fixtureEl.querySelector('div')!
vi.spyOn(div, 'getClientRects').mockReturnValue([{ width: 100, height: 100 }] as unknown as DOMRectList)
expect(isVisible(div)).toBe(false)
})
it('should return false for element inside closed details', () => {
fixtureEl.innerHTML = '<details><div id="inside">text</div></details>'
const inside = fixtureEl.querySelector('#inside')!
vi.spyOn(inside, 'getClientRects').mockReturnValue([{ width: 100, height: 100 }] as unknown as DOMRectList)
expect(isVisible(inside as HTMLElement)).toBe(false)
})
it('should return true for element inside open details', () => {
fixtureEl.innerHTML = '<details open><div id="inside">text</div></details>'
const inside = fixtureEl.querySelector('#inside')!
vi.spyOn(inside, 'getClientRects').mockReturnValue([{ width: 100, height: 100 }] as unknown as DOMRectList)
expect(isVisible(inside as HTMLElement)).toBe(true)
})
it('should return visible for direct summary in closed details', () => {
fixtureEl.innerHTML = '<details><summary id="sum">title</summary></details>'
const sum = fixtureEl.querySelector('#sum')!
vi.spyOn(sum, 'getClientRects').mockReturnValue([{ width: 100, height: 100 }] as unknown as DOMRectList)
expect(isVisible(sum as HTMLElement)).toBe(true)
})
it('should return false for nested summary inside closed details', () => {
fixtureEl.innerHTML = '<details><div><summary id="sum">title</summary></div></details>'
const sum = fixtureEl.querySelector('#sum')!
vi.spyOn(sum, 'getClientRects').mockReturnValue([{ width: 100, height: 100 }] as unknown as DOMRectList)
expect(isVisible(sum as HTMLElement)).toBe(false)
})
it('should return false for non-summary element inside closed details', () => {
fixtureEl.innerHTML = '<details><span id="inner">content</span></details>'
const inner = fixtureEl.querySelector('#inner')!
vi.spyOn(inner, 'getClientRects').mockReturnValue([{ width: 100, height: 100 }] as unknown as DOMRectList)
expect(isVisible(inner as HTMLElement)).toBe(false)
})
})
describe('isDisabled', () => {
it('should return true for null', () => {
expect(isDisabled(null)).toBe(true)
})
it('should return true for undefined', () => {
expect(isDisabled(undefined)).toBe(true)
})
it('should return true for element with disabled class', () => {
fixtureEl.innerHTML = '<div class="disabled"></div>'
expect(isDisabled(fixtureEl.querySelector('div')!)).toBe(true)
})
it('should return true for disabled button', () => {
fixtureEl.innerHTML = '<button disabled></button>'
expect(isDisabled(fixtureEl.querySelector('button')!)).toBe(true)
})
it('should return false for enabled button', () => {
fixtureEl.innerHTML = '<button></button>'
expect(isDisabled(fixtureEl.querySelector('button')!)).toBe(false)
})
it('should return true for element with disabled attribute', () => {
fixtureEl.innerHTML = '<div disabled></div>'
expect(isDisabled(fixtureEl.querySelector('div')!)).toBe(true)
})
it('should return false for disabled="false"', () => {
fixtureEl.innerHTML = '<div disabled="false"></div>'
expect(isDisabled(fixtureEl.querySelector('div')!)).toBe(false)
})
it('should return false for a normal div', () => {
fixtureEl.innerHTML = '<div></div>'
expect(isDisabled(fixtureEl.querySelector('div')!)).toBe(false)
})
})
describe('findShadowRoot', () => {
it('should return null when element has no shadow root', () => {
fixtureEl.innerHTML = '<div></div>'
expect(findShadowRoot(fixtureEl.querySelector('div')!)).toBeNull()
})
it('should return null for orphan node', () => {
const orphan = document.createTextNode('text')
expect(findShadowRoot(orphan)).toBeNull()
})
it('should return null if attachShadow is not supported', () => {
const original = document.documentElement.attachShadow
Object.defineProperty(document.documentElement, 'attachShadow', { value: undefined, configurable: true })
fixtureEl.innerHTML = '<div></div>'
expect(findShadowRoot(fixtureEl.querySelector('div')!)).toBeNull()
Object.defineProperty(document.documentElement, 'attachShadow', { value: original, configurable: true })
})
it('should return shadow root via getRootNode', () => {
const host = document.createElement('div')
document.body.appendChild(host)
const shadowRoot = host.attachShadow({ mode: 'open' })
const inner = document.createElement('span')
shadowRoot.appendChild(inner)
expect(findShadowRoot(inner)).toBe(shadowRoot)
host.remove()
})
it('should fallback to parentNode traversal if getRootNode is not available', () => {
const host = document.createElement('div')
document.body.appendChild(host)
const shadowRoot = host.attachShadow({ mode: 'open' })
const inner = document.createElement('span')
shadowRoot.appendChild(inner)
const originalGetRootNode = inner.getRootNode
Object.defineProperty(inner, 'getRootNode', { value: undefined, configurable: true })
expect(findShadowRoot(inner)).toBe(shadowRoot)
Object.defineProperty(inner, 'getRootNode', { value: originalGetRootNode, configurable: true })
host.remove()
})
})
describe('noop', () => {
it('should be a function that does nothing', () => {
expect(typeof noop).toBe('function')
expect(noop()).toBeUndefined()
})
})
describe('reflow', () => {
it('should access offsetHeight on the element', () => {
fixtureEl.innerHTML = '<div></div>'
const div = fixtureEl.querySelector('div')!
expect(() => reflow(div)).not.toThrow()
})
})
describe('isRTL', () => {
afterEach(() => {
document.documentElement.dir = ''
})
it('should return false by default (LTR)', () => {
expect(isRTL()).toBe(false)
})
it('should return true when dir is rtl', () => {
document.documentElement.dir = 'rtl'
expect(isRTL()).toBe(true)
})
})
describe('execute', () => {
it('should call a function and return its result', () => {
const fn = () => 42
expect(execute(fn)).toBe(42)
})
it('should return defaultValue if not a function', () => {
expect(execute('not a fn', [], 'default')).toBe('default')
})
it('should return the value itself as default if no defaultValue', () => {
expect(execute('hello')).toBe('hello')
})
it('should pass args to the function', () => {
const fn = vi.fn((..._args: unknown[]) => {})
execute(fn, ['context', 'arg1', 'arg2'])
expect(fn).toHaveBeenCalledWith('arg1', 'arg2')
})
})
describe('executeAfterTransition', () => {
it('should execute callback immediately when waitForTransition is false', () => {
fixtureEl.innerHTML = '<div></div>'
const div = fixtureEl.querySelector('div')!
const callback = vi.fn()
executeAfterTransition(callback, div, false)
expect(callback).toHaveBeenCalledOnce()
})
it('should execute callback after transitionend event', () => {
fixtureEl.innerHTML = '<div></div>'
const div = fixtureEl.querySelector('div')!
const callback = vi.fn()
executeAfterTransition(callback, div, true)
expect(callback).not.toHaveBeenCalled()
div.dispatchEvent(new Event('transitionend'))
expect(callback).toHaveBeenCalledOnce()
})
it('should ignore transitionend from a different target', () => {
fixtureEl.innerHTML = '<div id="parent"><span id="child"></span></div>'
const parent = fixtureEl.querySelector('#parent')!
const child = fixtureEl.querySelector('#child')!
const callback = vi.fn()
executeAfterTransition(callback, parent, true)
child.dispatchEvent(new Event('transitionend', { bubbles: true }))
expect(callback).not.toHaveBeenCalled()
parent.dispatchEvent(new Event('transitionend'))
expect(callback).toHaveBeenCalledOnce()
})
it('should execute via setTimeout fallback', async () => {
vi.useFakeTimers()
fixtureEl.innerHTML = '<div></div>'
const div = fixtureEl.querySelector('div')!
const callback = vi.fn()
executeAfterTransition(callback, div, true)
expect(callback).not.toHaveBeenCalled()
vi.advanceTimersByTime(10)
expect(callback).toHaveBeenCalledOnce()
vi.useRealTimers()
})
})
describe('getNextActiveElement', () => {
const list = ['a', 'b', 'c', 'd']
it('should return next element', () => {
expect(getNextActiveElement(list, 'b', true, false)).toBe('c')
})
it('should return previous element', () => {
expect(getNextActiveElement(list, 'c', false, false)).toBe('b')
})
it('should cycle to first when at end', () => {
expect(getNextActiveElement(list, 'd', true, true)).toBe('a')
})
it('should cycle to last when at start going back', () => {
expect(getNextActiveElement(list, 'a', false, true)).toBe('d')
})
it('should clamp to last when at end without cycling', () => {
expect(getNextActiveElement(list, 'd', true, false)).toBe('d')
})
it('should clamp to first when at start without cycling', () => {
expect(getNextActiveElement(list, 'a', false, false)).toBe('a')
})
it('should return first element when active is not in list', () => {
expect(getNextActiveElement(list, 'z', true, false)).toBe('a')
})
it('should return last element when active is not in list and going back with cycle', () => {
expect(getNextActiveElement(list, 'z', false, true)).toBe('d')
})
})
})

View File

@@ -0,0 +1,118 @@
import { describe, it, expect } from 'vitest'
import { sanitizeHtml, DefaultAllowlist } from '../../../src/bootstrap/util/sanitizer'
describe('sanitizer', () => {
describe('DefaultAllowlist', () => {
it('should have a wildcard entry with common attributes', () => {
expect(DefaultAllowlist['*']).toBeDefined()
const wildcardStrings = DefaultAllowlist['*'].filter(a => typeof a === 'string')
expect(wildcardStrings).toContain('class')
expect(wildcardStrings).toContain('id')
expect(wildcardStrings).toContain('role')
})
it('should allow safe tags', () => {
for (const tag of ['a', 'b', 'br', 'div', 'em', 'h1', 'img', 'li', 'ol', 'p', 'span', 'strong', 'ul']) {
expect(tag in DefaultAllowlist).toBe(true)
}
})
it('should allow href/target/title/rel for <a>', () => {
expect(DefaultAllowlist.a).toEqual(expect.arrayContaining(['href', 'target', 'title', 'rel']))
})
})
describe('sanitizeHtml', () => {
it('should return empty string for empty input', () => {
expect(sanitizeHtml('', DefaultAllowlist)).toBe('')
})
it('should use custom sanitize function when provided', () => {
const customFn = (html: string) => html.toUpperCase()
expect(sanitizeHtml('<b>test</b>', DefaultAllowlist, customFn)).toBe('<B>TEST</B>')
})
it('should keep allowed elements', () => {
const result = sanitizeHtml('<b>bold</b>', DefaultAllowlist)
expect(result).toContain('<b>')
expect(result).toContain('bold')
})
it('should remove disallowed elements', () => {
const result = sanitizeHtml('<script>alert("xss")</script><b>safe</b>', DefaultAllowlist)
expect(result).not.toContain('<script>')
expect(result).not.toContain('alert')
expect(result).toContain('<b>')
})
it('should keep allowed attributes', () => {
const result = sanitizeHtml('<a href="https://example.com" title="link">click</a>', DefaultAllowlist)
expect(result).toContain('href')
expect(result).toContain('title')
})
it('should remove disallowed attributes', () => {
const result = sanitizeHtml('<b onclick="alert(1)">bold</b>', DefaultAllowlist)
expect(result).not.toContain('onclick')
expect(result).toContain('<b>')
})
it('should allow class attribute via wildcard', () => {
const result = sanitizeHtml('<div class="my-class">content</div>', DefaultAllowlist)
expect(result).toContain('class="my-class"')
})
it('should allow aria-* attributes via regex', () => {
const result = sanitizeHtml('<div aria-label="test" aria-hidden="true">content</div>', DefaultAllowlist)
expect(result).toContain('aria-label')
expect(result).toContain('aria-hidden')
})
it('should block javascript: URIs in href', () => {
const result = sanitizeHtml('<a href="javascript:alert(1)">xss</a>', DefaultAllowlist)
expect(result).not.toContain('javascript:')
})
it('should allow safe URIs in href', () => {
const result = sanitizeHtml('<a href="https://example.com">link</a>', DefaultAllowlist)
expect(result).toContain('href="https://example.com"')
})
it('should allow relative URIs', () => {
const result = sanitizeHtml('<a href="/path/to/page">link</a>', DefaultAllowlist)
expect(result).toContain('href="/path/to/page"')
})
it('should block javascript: URIs in src', () => {
const result = sanitizeHtml('<img src="javascript:alert(1)">', DefaultAllowlist)
expect(result).not.toContain('javascript:')
})
it('should allow safe img src', () => {
const result = sanitizeHtml('<img src="image.png" alt="pic">', DefaultAllowlist)
expect(result).toContain('src="image.png"')
expect(result).toContain('alt="pic"')
})
it('should handle nested allowed elements', () => {
const result = sanitizeHtml('<div><p><strong>text</strong></p></div>', DefaultAllowlist)
expect(result).toContain('<div>')
expect(result).toContain('<p>')
expect(result).toContain('<strong>')
})
it('should strip disallowed elements but keep text content', () => {
const result = sanitizeHtml('<custom-tag>text</custom-tag><b>bold</b>', DefaultAllowlist)
expect(result).not.toContain('<custom-tag>')
expect(result).toContain('<b>bold</b>')
})
it('should work with custom allowList', () => {
const customList = { b: [], i: [] }
const result = sanitizeHtml('<b>bold</b><div>removed</div><i>italic</i>', customList)
expect(result).toContain('<b>')
expect(result).toContain('<i>')
expect(result).not.toContain('<div>')
})
})
})

View File

@@ -0,0 +1,202 @@
import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach, vi } from 'vitest'
import ScrollBarHelper from '../../../src/bootstrap/util/scrollbar'
import Manipulator from '../../../src/bootstrap/dom/manipulator'
import { clearBodyAndDocument, clearFixture, getFixture } from '../../helpers/fixture'
describe('ScrollBar', () => {
let fixtureEl: HTMLElement
beforeAll(() => {
fixtureEl = getFixture()
fixtureEl.removeAttribute('style')
})
afterAll(() => {
fixtureEl.remove()
})
beforeEach(() => {
clearBodyAndDocument()
})
afterEach(() => {
clearFixture()
clearBodyAndDocument()
vi.restoreAllMocks()
})
describe('getWidth', () => {
it('should return the difference between innerWidth and clientWidth', () => {
const scrollBar = new ScrollBarHelper()
const expected = Math.abs(window.innerWidth - document.documentElement.clientWidth)
expect(scrollBar.getWidth()).toBe(expected)
})
it('should return 0 when innerWidth equals clientWidth', () => {
vi.spyOn(document.documentElement, 'clientWidth', 'get').mockReturnValue(window.innerWidth)
expect(new ScrollBarHelper().getWidth()).toBe(0)
})
})
describe('isOverflowing', () => {
it('should return true when getWidth > 0', () => {
const scrollBar = new ScrollBarHelper()
vi.spyOn(scrollBar, 'getWidth').mockReturnValue(15)
expect(scrollBar.isOverflowing()).toBe(true)
})
it('should return false when getWidth is 0', () => {
const scrollBar = new ScrollBarHelper()
vi.spyOn(scrollBar, 'getWidth').mockReturnValue(0)
expect(scrollBar.isOverflowing()).toBe(false)
})
})
describe('hide', () => {
it('should set overflow hidden on body', () => {
vi.spyOn(ScrollBarHelper.prototype, 'getWidth').mockReturnValue(0)
const scrollBar = new ScrollBarHelper()
scrollBar.hide()
expect(document.body.style.overflow).toBe('hidden')
scrollBar.reset()
})
it('should adjust body padding-right by scrollbar width', () => {
vi.spyOn(ScrollBarHelper.prototype, 'getWidth').mockReturnValue(15)
document.body.style.paddingRight = '5px'
const scrollBar = new ScrollBarHelper()
scrollBar.hide()
expect(document.body.style.paddingRight).toBe('20px')
scrollBar.reset()
})
it('should save initial padding-right as data attribute', () => {
vi.spyOn(ScrollBarHelper.prototype, 'getWidth').mockReturnValue(15)
document.body.style.paddingRight = '5px'
const scrollBar = new ScrollBarHelper()
scrollBar.hide()
expect(Manipulator.getDataAttribute(document.body, 'padding-right')).toBe('5px')
scrollBar.reset()
})
it('should adjust fixed elements padding', () => {
vi.spyOn(ScrollBarHelper.prototype, 'getWidth').mockReturnValue(15)
fixtureEl.innerHTML = '<div class="fixed-top" style="padding-right: 0px; width: 100vw"></div>'
document.body.appendChild(fixtureEl)
const fixedEl = fixtureEl.querySelector('.fixed-top')! as HTMLElement
// jsdom doesn't compute layout, so mock clientWidth to simulate full-width element
vi.spyOn(fixedEl, 'clientWidth', 'get').mockReturnValue(window.innerWidth - 15)
const scrollBar = new ScrollBarHelper()
scrollBar.hide()
expect(fixedEl.style.paddingRight).toBe('15px')
scrollBar.reset()
})
})
describe('reset', () => {
it('should restore overflow on body', () => {
vi.spyOn(ScrollBarHelper.prototype, 'getWidth').mockReturnValue(0)
document.body.style.overflow = 'scroll'
const scrollBar = new ScrollBarHelper()
scrollBar.hide()
expect(document.body.style.overflow).toBe('hidden')
scrollBar.reset()
expect(document.body.style.overflow).toBe('scroll')
})
it('should restore body padding-right', () => {
vi.spyOn(ScrollBarHelper.prototype, 'getWidth').mockReturnValue(15)
document.body.style.paddingRight = '5px'
const scrollBar = new ScrollBarHelper()
scrollBar.hide()
expect(document.body.style.paddingRight).toBe('20px')
scrollBar.reset()
expect(document.body.style.paddingRight).toBe('5px')
})
it('should remove padding-right if it was not set before', () => {
vi.spyOn(ScrollBarHelper.prototype, 'getWidth').mockReturnValue(15)
document.body.style.removeProperty('padding-right')
const scrollBar = new ScrollBarHelper()
scrollBar.hide()
scrollBar.reset()
expect(document.body.style.paddingRight).toBe('')
})
it('should remove data attribute after reset', () => {
vi.spyOn(ScrollBarHelper.prototype, 'getWidth').mockReturnValue(15)
document.body.style.paddingRight = '5px'
const scrollBar = new ScrollBarHelper()
scrollBar.hide()
scrollBar.reset()
expect(Manipulator.getDataAttribute(document.body, 'padding-right')).toBeNull()
})
it('should preserve other inline styles', () => {
vi.spyOn(ScrollBarHelper.prototype, 'getWidth').mockReturnValue(0)
document.body.style.color = 'red'
const scrollBar = new ScrollBarHelper()
scrollBar.hide()
expect(document.body.style.color).toBe('red')
scrollBar.reset()
expect(document.body.style.color).toBe('red')
})
it('should reset sticky elements margin', () => {
vi.spyOn(ScrollBarHelper.prototype, 'getWidth').mockReturnValue(15)
fixtureEl.innerHTML = '<div class="sticky-top" style="margin-right: 10px; width: 100vw"></div>'
document.body.appendChild(fixtureEl)
const stickyEl = fixtureEl.querySelector('.sticky-top')! as HTMLElement
vi.spyOn(stickyEl, 'clientWidth', 'get').mockReturnValue(window.innerWidth - 15)
const scrollBar = new ScrollBarHelper()
scrollBar.hide()
expect(Manipulator.getDataAttribute(stickyEl, 'margin-right')).toBe('10px')
scrollBar.reset()
expect(stickyEl.style.marginRight).toBe('10px')
expect(Manipulator.getDataAttribute(stickyEl, 'margin-right')).toBeNull()
})
it('should skip non-full-width elements', () => {
vi.spyOn(ScrollBarHelper.prototype, 'getWidth').mockReturnValue(15)
fixtureEl.innerHTML = '<div class="sticky-top" style="margin-right: 0px; padding-right: 0px; width: 50vw"></div>'
document.body.appendChild(fixtureEl)
const stickyEl = fixtureEl.querySelector('.sticky-top')! as HTMLElement
// clientWidth 0 (jsdom default) + scrollbarWidth 15 = 15, which is < innerWidth 1024
// So this element will be skipped
const scrollBar = new ScrollBarHelper()
scrollBar.hide()
expect(stickyEl.style.paddingRight).toBe('0px')
expect(stickyEl.style.marginRight).toBe('0px')
scrollBar.reset()
})
})
})

View File

@@ -0,0 +1,311 @@
import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest'
import Swipe from '../../../src/bootstrap/util/swipe'
import { clearFixture, getFixture } from '../../helpers/fixture'
function mockTouchSupport() {
Object.defineProperty(document.documentElement, 'ontouchstart', {
value: () => {},
configurable: true
})
}
function clearTouchSupport() {
delete (document.documentElement as Record<string, unknown>).ontouchstart
}
function createTouchEvent(type: string, touches: Array<{ clientX: number }>, target: EventTarget): TouchEvent {
const touchList = touches.map((t, i) => ({
identifier: i,
target,
clientX: t.clientX,
clientY: 0,
pageX: 0,
pageY: 0,
screenX: 0,
screenY: 0,
radiusX: 0,
radiusY: 0,
rotationAngle: 0,
force: 0
})) as unknown as Touch[]
const event = new Event(type, { bubbles: true, cancelable: true }) as TouchEvent
Object.defineProperty(event, 'touches', { value: touchList })
Object.defineProperty(event, 'changedTouches', { value: touchList })
return event
}
function createPointerEvent(type: string, opts: Partial<PointerEvent> = {}): PointerEvent {
return new PointerEvent(type, {
bubbles: true,
cancelable: true,
clientX: opts.clientX ?? 0,
pointerType: (opts as Record<string, unknown>).pointerType as string ?? 'touch',
...opts
})
}
describe('Swipe', () => {
let fixtureEl: HTMLElement
let div: HTMLElement
beforeAll(() => {
fixtureEl = getFixture()
})
beforeEach(() => {
mockTouchSupport()
fixtureEl.innerHTML = '<div style="width:300px;height:300px;"></div>'
div = fixtureEl.querySelector('div')!
})
afterEach(() => {
clearTouchSupport()
clearFixture()
})
describe('constructor', () => {
it('should create a Swipe instance', () => {
const swipe = new Swipe(div)
expect(swipe).toBeInstanceOf(Swipe)
swipe.dispose()
})
it('should not init events when touch is not supported', () => {
vi.spyOn(Swipe, 'isSupported').mockReturnValue(false)
const swipe = new Swipe(div)
expect(swipe._deltaX).toBeUndefined()
vi.restoreAllMocks()
})
})
describe('static', () => {
it('NAME should return "swipe"', () => {
expect(Swipe.NAME).toBe('swipe')
})
it('isSupported should return true when ontouchstart exists', () => {
expect(Swipe.isSupported()).toBe(true)
})
it('isSupported should check ontouchstart and maxTouchPoints', () => {
expect(typeof Swipe.isSupported()).toBe('boolean')
})
})
describe('dispose', () => {
it('should remove event listeners', () => {
const leftCallback = vi.fn()
const swipe = new Swipe(div, { leftCallback })
swipe.dispose()
div.dispatchEvent(createPointerEvent('pointerdown', { clientX: 300 }))
div.dispatchEvent(createPointerEvent('pointerup', { clientX: 100 }))
expect(leftCallback).not.toHaveBeenCalled()
})
})
describe('pointer events', () => {
it('should detect a left swipe', () => {
const leftCallback = vi.fn()
const swipe = new Swipe(div, { leftCallback })
div.dispatchEvent(createPointerEvent('pointerdown', { clientX: 300 }))
div.dispatchEvent(createPointerEvent('pointerup', { clientX: 100 }))
expect(leftCallback).toHaveBeenCalledOnce()
swipe.dispose()
})
it('should detect a right swipe', () => {
const rightCallback = vi.fn()
const swipe = new Swipe(div, { rightCallback })
div.dispatchEvent(createPointerEvent('pointerdown', { clientX: 100 }))
div.dispatchEvent(createPointerEvent('pointerup', { clientX: 300 }))
expect(rightCallback).toHaveBeenCalledOnce()
swipe.dispose()
})
it('should call endCallback after swipe', () => {
const endCallback = vi.fn()
const swipe = new Swipe(div, { endCallback })
div.dispatchEvent(createPointerEvent('pointerdown', { clientX: 300 }))
div.dispatchEvent(createPointerEvent('pointerup', { clientX: 100 }))
expect(endCallback).toHaveBeenCalledOnce()
swipe.dispose()
})
it('should not trigger callback when swipe is below threshold', () => {
const leftCallback = vi.fn()
const rightCallback = vi.fn()
const swipe = new Swipe(div, { leftCallback, rightCallback })
div.dispatchEvent(createPointerEvent('pointerdown', { clientX: 100 }))
div.dispatchEvent(createPointerEvent('pointerup', { clientX: 120 }))
expect(leftCallback).not.toHaveBeenCalled()
expect(rightCallback).not.toHaveBeenCalled()
swipe.dispose()
})
it('should ignore non-touch/pen pointer types', () => {
const leftCallback = vi.fn()
const swipe = new Swipe(div, { leftCallback })
div.dispatchEvent(new PointerEvent('pointerdown', {
bubbles: true,
clientX: 300,
pointerType: 'mouse'
}))
div.dispatchEvent(new PointerEvent('pointerup', {
bubbles: true,
clientX: 100,
pointerType: 'mouse'
}))
expect(leftCallback).not.toHaveBeenCalled()
swipe.dispose()
})
it('should accept pen pointer type', () => {
const leftCallback = vi.fn()
const swipe = new Swipe(div, { leftCallback })
div.dispatchEvent(new PointerEvent('pointerdown', {
bubbles: true,
clientX: 300,
pointerType: 'pen'
}))
div.dispatchEvent(new PointerEvent('pointerup', {
bubbles: true,
clientX: 100,
pointerType: 'pen'
}))
expect(leftCallback).toHaveBeenCalledOnce()
swipe.dispose()
})
it('should add pointer-event class to element', () => {
const swipe = new Swipe(div)
expect(div.classList.contains('pointer-event')).toBe(true)
swipe.dispose()
})
})
describe('_handleSwipe edge cases', () => {
it('should not trigger callbacks when deltaX is zero', () => {
const leftCallback = vi.fn()
const rightCallback = vi.fn()
const swipe = new Swipe(div, { leftCallback, rightCallback })
div.dispatchEvent(createPointerEvent('pointerdown', { clientX: 200 }))
div.dispatchEvent(createPointerEvent('pointerup', { clientX: 200 }))
expect(leftCallback).not.toHaveBeenCalled()
expect(rightCallback).not.toHaveBeenCalled()
swipe.dispose()
})
it('should reset deltaX after handling swipe', () => {
const leftCallback = vi.fn()
const swipe = new Swipe(div, { leftCallback })
div.dispatchEvent(createPointerEvent('pointerdown', { clientX: 300 }))
div.dispatchEvent(createPointerEvent('pointerup', { clientX: 100 }))
expect(leftCallback).toHaveBeenCalledOnce()
div.dispatchEvent(createPointerEvent('pointerdown', { clientX: 300 }))
div.dispatchEvent(createPointerEvent('pointerup', { clientX: 100 }))
expect(leftCallback).toHaveBeenCalledTimes(2)
swipe.dispose()
})
})
describe('touch events (no PointerEvent)', () => {
const origPointerEvent = globalThis.PointerEvent
beforeEach(() => {
// @ts-expect-error removing PointerEvent to test touch fallback
delete globalThis.PointerEvent
})
afterEach(() => {
globalThis.PointerEvent = origPointerEvent
})
it('should detect a left swipe via touch events', () => {
const leftCallback = vi.fn()
const swipe = new Swipe(div, { leftCallback })
div.dispatchEvent(createTouchEvent('touchstart', [{ clientX: 300 }], div))
div.dispatchEvent(createTouchEvent('touchmove', [{ clientX: 100 }], div))
div.dispatchEvent(createTouchEvent('touchend', [], div))
expect(leftCallback).toHaveBeenCalledOnce()
swipe.dispose()
})
it('should detect a right swipe via touch events', () => {
const rightCallback = vi.fn()
const swipe = new Swipe(div, { rightCallback })
div.dispatchEvent(createTouchEvent('touchstart', [{ clientX: 100 }], div))
div.dispatchEvent(createTouchEvent('touchmove', [{ clientX: 300 }], div))
div.dispatchEvent(createTouchEvent('touchend', [], div))
expect(rightCallback).toHaveBeenCalledOnce()
swipe.dispose()
})
it('should reset deltaX to 0 on multi-touch move', () => {
const leftCallback = vi.fn()
const swipe = new Swipe(div, { leftCallback })
div.dispatchEvent(createTouchEvent('touchstart', [{ clientX: 300 }], div))
div.dispatchEvent(createTouchEvent('touchmove', [{ clientX: 200 }, { clientX: 250 }], div))
div.dispatchEvent(createTouchEvent('touchend', [], div))
expect(leftCallback).not.toHaveBeenCalled()
swipe.dispose()
})
it('should not add pointer-event class', () => {
const swipe = new Swipe(div)
expect(div.classList.contains('pointer-event')).toBe(false)
swipe.dispose()
})
})
describe('Default / DefaultType', () => {
it('should have correct Default values', () => {
expect(Swipe.Default).toEqual({
endCallback: null,
leftCallback: null,
rightCallback: null
})
})
it('should have correct DefaultType values', () => {
expect(Swipe.DefaultType).toEqual({
endCallback: '(function|null)',
leftCallback: '(function|null)',
rightCallback: '(function|null)'
})
})
})
})

View File

@@ -0,0 +1,233 @@
import { describe, it, expect, beforeAll, afterEach } from 'vitest'
import TemplateFactory from '../../../src/bootstrap/util/template-factory'
import { clearFixture, getFixture } from '../../helpers/fixture'
describe('TemplateFactory', () => {
let fixtureEl: HTMLElement
beforeAll(() => {
fixtureEl = getFixture()
})
afterEach(() => {
clearFixture()
})
describe('NAME', () => {
it('should return plugin NAME', () => {
expect(TemplateFactory.NAME).toBe('TemplateFactory')
})
})
describe('Default', () => {
it('should return plugin default config', () => {
expect(typeof TemplateFactory.Default).toBe('object')
})
})
describe('toHtml', () => {
describe('Sanitization', () => {
it('should sanitize template by default', () => {
const factory = new TemplateFactory({
sanitize: true,
template: '<div><a href="javascript:alert(7)">Click me</a></div>'
})
expect(factory.toHtml().innerHTML).not.toContain('href="javascript:alert(7)')
})
it('should not sanitize template if disabled', () => {
const factory = new TemplateFactory({
sanitize: false,
template: '<div><a href="javascript:alert(7)">Click me</a></div>'
})
expect(factory.toHtml().innerHTML).toContain('href="javascript:alert(7)')
})
it('should sanitize html content', () => {
const factory = new TemplateFactory({
sanitize: true,
html: true,
template: '<div id="foo"></div>',
content: { '#foo': '<a href="javascript:alert(7)">Click me</a>' }
})
expect(factory.toHtml().innerHTML).not.toContain('href="javascript:alert(7)')
})
it('should not sanitize content when disabled', () => {
const factory = new TemplateFactory({
sanitize: false,
html: true,
template: '<div id="foo"></div>',
content: { '#foo': '<a href="javascript:alert(7)">Click me</a>' }
})
expect(factory.toHtml().innerHTML).toContain('href="javascript:alert(7)')
})
})
describe('Extra Class', () => {
it('should add extra class', () => {
const factory = new TemplateFactory({ extraClass: 'testClass' })
expect(factory.toHtml().classList.contains('testClass')).toBe(true)
})
it('should add multiple extra classes', () => {
const factory = new TemplateFactory({ extraClass: 'testClass testClass2' })
const el = factory.toHtml()
expect(el.classList.contains('testClass')).toBe(true)
expect(el.classList.contains('testClass2')).toBe(true)
})
it('should resolve class from function', () => {
const factory = new TemplateFactory({
extraClass() {
return 'testClass'
}
})
expect(factory.toHtml().classList.contains('testClass')).toBe(true)
})
})
})
describe('Content', () => {
it('should add simple text content', () => {
const template = '<div><div class="foo"></div><div class="foo2"></div></div>'
const factory = new TemplateFactory({
template,
content: { '.foo': 'bar', '.foo2': 'bar2' }
})
const html = factory.toHtml()
expect(html.querySelector('.foo')!.textContent).toBe('bar')
expect(html.querySelector('.foo2')!.textContent).toBe('bar2')
})
it('should not fill template if selector does not exist', () => {
const factory = new TemplateFactory({
sanitize: true,
html: true,
template: '<div id="foo"></div>',
content: { '#bar': 'test' }
})
expect(factory.toHtml().outerHTML).toBe('<div id="foo"></div>')
})
it('should remove template selector if content is null', () => {
const factory = new TemplateFactory({
sanitize: true,
html: true,
template: '<div><div id="foo"></div></div>',
content: { '#foo': null }
})
expect(factory.toHtml().outerHTML).toBe('<div></div>')
})
it('should resolve content from function', () => {
const factory = new TemplateFactory({
sanitize: true,
html: true,
template: '<div><div id="foo"></div></div>',
content: { '#foo': () => null }
})
expect(factory.toHtml().outerHTML).toBe('<div></div>')
})
it('should use textContent when html is false and content is element', () => {
fixtureEl.innerHTML = '<div>foo<span>bar</span></div>'
const contentElement = fixtureEl.querySelector('div')!
const factory = new TemplateFactory({
html: false,
template: '<div><div id="foo"></div></div>',
content: { '#foo': contentElement }
})
const fooEl = factory.toHtml().querySelector('#foo')!
expect(fooEl.innerHTML).not.toBe(contentElement.innerHTML)
expect(fooEl.textContent).toBe(contentElement.textContent)
expect(fooEl.textContent).toBe('foobar')
})
it('should use outerHTML when html is true and content is element', () => {
fixtureEl.innerHTML = '<div>foo<span>bar</span></div>'
const contentElement = fixtureEl.querySelector('div')!
const factory = new TemplateFactory({
html: true,
template: '<div><div id="foo"></div></div>',
content: { '#foo': contentElement }
})
const fooEl = factory.toHtml().querySelector('#foo')!
expect(fooEl.innerHTML).toBe(contentElement.outerHTML)
})
})
describe('getContent', () => {
it('should get content as array', () => {
const factory = new TemplateFactory({
content: { '.foo': 'bar', '.foo2': 'bar2' }
})
expect(factory.getContent()).toEqual(['bar', 'bar2'])
})
it('should filter empties', () => {
const factory = new TemplateFactory({
content: {
'.foo': 'bar',
'.foo2': '',
'.foo3': null,
'.foo4': () => 2,
'.foo5': () => null
}
})
expect(factory.getContent()).toEqual(['bar', 2])
})
})
describe('hasContent', () => {
it('should return true if it has content', () => {
const factory = new TemplateFactory({
content: { '.foo': 'bar', '.foo2': 'bar2', '.foo3': '' }
})
expect(factory.hasContent()).toBe(true)
})
it('should return false if all content is empty', () => {
const factory = new TemplateFactory({
content: { '.foo2': '', '.foo3': null, '.foo4': () => null }
})
expect(factory.hasContent()).toBe(false)
})
})
describe('changeContent', () => {
it('should change content', () => {
const template = '<div><div class="foo"></div><div class="foo2"></div></div>'
const factory = new TemplateFactory({
template,
content: { '.foo': 'bar', '.foo2': 'bar2' }
})
const text = (sel: string) => factory.toHtml().querySelector(sel)!.textContent
expect(text('.foo')).toBe('bar')
expect(text('.foo2')).toBe('bar2')
factory.changeContent({ '.foo': 'test', '.foo2': 'test2' })
expect(text('.foo')).toBe('test')
expect(text('.foo2')).toBe('test2')
})
it('should change only specified content', () => {
const template = '<div><div class="foo"></div><div class="foo2"></div></div>'
const factory = new TemplateFactory({
template,
content: { '.foo': 'bar', '.foo2': 'bar2' }
})
const text = (sel: string) => factory.toHtml().querySelector(sel)!.textContent
factory.changeContent({ '.foo': 'test' })
expect(text('.foo')).toBe('test')
expect(text('.foo2')).toBe('bar2')
})
})
})

View File

@@ -0,0 +1,49 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="../../../dist/css/tabler.min.css" rel="stylesheet">
<title>Alert</title>
</head>
<body>
<div class="container">
<h1>Alert <small>Bootstrap Visual Test</small></h1>
<div class="alert alert-warning alert-dismissible fade show" role="alert">
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
<strong>Holy guacamole!</strong> You should check in on some of those fields below.
</div>
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
<p>
<strong>Oh snap!</strong> <a href="#" class="alert-link">Change a few things up</a> and try submitting again.
</p>
<p>
<button type="button" class="btn btn-danger">Danger</button>
<button type="button" class="btn btn-secondary">Secondary</button>
</p>
</div>
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
<p>
<strong>Oh snap!</strong> <a href="#" class="alert-link">Change a few things up</a> and try submitting again. Duis mollis, est non commodo luctus, nisi erat porttitor ligula, eget lacinia odio sem nec elit. Cras mattis consectetur purus sit amet fermentum.
</p>
<p>
<button type="button" class="btn btn-danger">Take this action</button>
<button type="button" class="btn btn-primary">Or do this</button>
</p>
</div>
<div class="alert alert-warning alert-dismissible fade show" role="alert" style="transition-duration: 5s;">
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
This alert will take 5 seconds to fade out.
</div>
</div>
<script src="../../../dist/js/tabler.min.js"></script>
<script>window.bootstrap = window.tabler && window.tabler.bootstrap || {};</script>
</body>
</html>

View File

@@ -0,0 +1,50 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="../../../dist/css/tabler.min.css" rel="stylesheet">
<title>Button</title>
</head>
<body>
<div class="container">
<h1>Button <small>Bootstrap Visual Test</small></h1>
<button type="button" class="btn btn-primary" data-bs-toggle="button" aria-pressed="false" autocomplete="off">
Single toggle
</button>
<p>For checkboxes and radio buttons, ensure that keyboard behavior is functioning correctly.</p>
<p>Navigate to the checkboxes with the keyboard (generally, using <kbd>Tab</kbd> / <kbd><kbd>Shift</kbd> + <kbd>Tab</kbd></kbd>), and ensure that <kbd>Space</kbd> toggles the currently focused checkbox. Click on one of the checkboxes using the mouse, ensure that focus was correctly set on the actual checkbox, and that <kbd>Space</kbd> toggles the checkbox again.</p>
<div class="btn-group" data-bs-toggle="buttons">
<label class="btn btn-primary active">
<input type="checkbox" checked autocomplete="off"> Checkbox 1 (pre-checked)
</label>
<label class="btn btn-primary">
<input type="checkbox" autocomplete="off"> Checkbox 2
</label>
<label class="btn btn-primary">
<input type="checkbox" autocomplete="off"> Checkbox 3
</label>
</div>
<p>Navigate to the radio button group with the keyboard (generally, using <kbd>Tab</kbd> / <kbd><kbd>Shift</kbd> + <kbd>Tab</kbd></kbd>). If no radio button was initially set to be selected, the first/last radio button should receive focus (depending on whether you navigated "forward" to the group with <kbd>Tab</kbd> or "backwards" using <kbd><kbd>Shift</kbd> + <kbd>Tab</kbd></kbd>). If a radio button was already selected, navigating with the keyboard should set focus to that particular radio button. Only one radio button in a group should receive focus at any given time. Ensure that the selected radio button can be changed by using the <kbd></kbd> and <kbd></kbd> arrow keys. Click on one of the radio buttons with the mouse, ensure that focus was correctly set on the actual radio button, and that <kbd></kbd> and <kbd></kbd> change the selected radio button again.</p>
<div class="btn-group" data-bs-toggle="buttons">
<label class="btn btn-primary active">
<input type="radio" name="options" id="option1" autocomplete="off" checked> Radio 1 (preselected)
</label>
<label class="btn btn-primary">
<input type="radio" name="options" id="option2" autocomplete="off"> Radio 2
</label>
<label class="btn btn-primary">
<input type="radio" name="options" id="option3" autocomplete="off"> Radio 3
</label>
</div>
</div>
<script src="../../../dist/js/tabler.min.js"></script>
<script>window.bootstrap = window.tabler && window.tabler.bootstrap || {};</script>
</body>
</html>

View File

@@ -0,0 +1,66 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="../../../dist/css/tabler.min.css" rel="stylesheet">
<title>Carousel</title>
<style>
.carousel-item {
transition: transform 2s ease, opacity .5s ease;
}
</style>
</head>
<body>
<div class="container">
<h1>Carousel <small>Bootstrap Visual Test</small></h1>
<p>The transition duration should be around 2s. Also, the carousel shouldn't slide when its window/tab is hidden. Check the console log.</p>
<div id="carousel-example-generic" class="carousel slide" data-bs-ride="carousel">
<div class="carousel-indicators">
<button type="button" data-bs-target="#carousel-example-generic" data-bs-slide-to="0" class="active" aria-current="true" aria-label="Slide 1"></button>
<button type="button" data-bs-target="#carousel-example-generic" data-bs-slide-to="1" aria-label="Slide 2"></button>
<button type="button" data-bs-target="#carousel-example-generic" data-bs-slide-to="2" aria-label="Slide 3"></button>
</div>
<div class="carousel-inner">
<div class="carousel-item active">
<img src="https://i.imgur.com/iEZgY7Y.jpg" alt="First slide">
</div>
<div class="carousel-item">
<img src="https://i.imgur.com/eNWn1Xs.jpg" alt="Second slide">
</div>
<div class="carousel-item">
<img src="https://i.imgur.com/Nm7xoti.jpg" alt="Third slide">
</div>
</div>
<button class="carousel-control-prev" data-bs-target="#carousel-example-generic" type="button" data-bs-slide="prev">
<span class="carousel-control-prev-icon" aria-hidden="true"></span>
<span class="visually-hidden">Previous</span>
</button>
<button class="carousel-control-next" data-bs-target="#carousel-example-generic" type="button" data-bs-slide="next">
<span class="carousel-control-next-icon" aria-hidden="true"></span>
<span class="visually-hidden">Next</span>
</button>
</div>
</div>
<script src="../../../dist/js/tabler.min.js"></script>
<script>window.bootstrap = window.tabler && window.tabler.bootstrap || {};</script>
<script>
let t0
let t1
const carousel = document.getElementById('carousel-example-generic')
// Test to show that the carousel doesn't slide when the current tab isn't visible
// Test to show that transition-duration can be changed with css
carousel.addEventListener('slid.bs.carousel', event => {
t1 = performance.now()
console.log(`transition-duration took ${t1 - t0}ms, slid at ${event.timeStamp}`)
})
carousel.addEventListener('slide.bs.carousel', () => {
t0 = performance.now()
})
</script>
</body>
</html>

View File

@@ -0,0 +1,77 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="../../../dist/css/tabler.min.css" rel="stylesheet">
<title>Collapse</title>
</head>
<body>
<div class="container">
<h1>Collapse <small>Bootstrap Visual Test</small></h1>
<div id="accordion" role="tablist">
<div class="card" role="presentation">
<div class="card-header" role="tab" id="headingOne">
<h5 class="mb-0">
<a data-bs-toggle="collapse" href="#collapseOne" aria-expanded="true" aria-controls="collapseOne">
Collapsible Group Item #1
</a>
</h5>
</div>
<div id="collapseOne" class="collapse show" data-bs-parent="#accordion" role="tabpanel" aria-labelledby="headingOne">
<div class="card-body">
Anim pariatur cliche reprehenderit, enim eiusmod high life accusamus terry richardson ad squid. 3 wolf moon officia aute, non cupidatat skateboard dolor brunch. Food truck quinoa nesciunt laborum eiusmod. Brunch 3 wolf moon tempor, sunt aliqua put a bird on it squid single-origin coffee nulla assumenda shoreditch et. Nihil anim keffiyeh helvetica, craft beer labore wes anderson cred nesciunt sapiente ea proident. Ad vegan excepteur butcher vice lomo. Leggings occaecat craft beer farm-to-table, raw denim aesthetic synth nesciunt you probably haven't heard of them accusamus labore sustainable VHS.
</div>
</div>
</div>
<div class="card" role="presentation">
<div class="card-header" role="tab" id="headingTwo">
<h5 class="mb-0">
<a class="collapsed" data-bs-toggle="collapse" href="#collapseTwo" aria-expanded="false" aria-controls="collapseTwo">
Collapsible Group Item #2
</a>
</h5>
</div>
<div id="collapseTwo" class="collapse" data-bs-parent="#accordion" role="tabpanel" aria-labelledby="headingTwo">
<div class="card-body">
Anim pariatur cliche reprehenderit, enim eiusmod high life accusamus terry richardson ad squid. 3 wolf moon officia aute, non cupidatat skateboard dolor brunch. Food truck quinoa nesciunt laborum eiusmod. Brunch 3 wolf moon tempor, sunt aliqua put a bird on it squid single-origin coffee nulla assumenda shoreditch et. Nihil anim keffiyeh helvetica, craft beer labore wes anderson cred nesciunt sapiente ea proident. Ad vegan excepteur butcher vice lomo. Leggings occaecat craft beer farm-to-table, raw denim aesthetic synth nesciunt you probably haven't heard of them accusamus labore sustainable VHS.
</div>
</div>
</div>
<div class="card" role="presentation">
<div class="card-header" role="tab" id="headingThree">
<h5 class="mb-0">
<a class="collapsed" data-bs-toggle="collapse" href="#collapseThree" aria-expanded="false" aria-controls="collapseThree">
Collapsible Group Item #3
</a>
</h5>
</div>
<div id="collapseThree" class="collapse" data-bs-parent="#accordion" role="tabpanel" aria-labelledby="headingThree">
<div class="card-body">
Anim pariatur cliche reprehenderit, enim eiusmod high life accusamus terry richardson ad squid. 3 wolf moon officia aute, non cupidatat skateboard dolor brunch. Food truck quinoa nesciunt laborum eiusmod. Brunch 3 wolf moon tempor, sunt aliqua put a bird on it squid single-origin coffee nulla assumenda shoreditch et. Nihil anim keffiyeh helvetica, craft beer labore wes anderson cred nesciunt sapiente ea proident. Ad vegan excepteur butcher vice lomo. Leggings occaecat craft beer farm-to-table, raw denim aesthetic synth nesciunt you probably haven't heard of them accusamus labore sustainable VHS.
</div>
</div>
</div>
<div class="card" role="presentation">
<div class="card-header" role="tab" id="headingFour">
<h5 class="mb-0">
<a class="collapsed" data-bs-toggle="collapse" href="#collapseFour" aria-expanded="false" aria-controls="collapseFour">
Collapsible Group Item with XSS in data-bs-parent
</a>
</h5>
</div>
<div id="collapseFour" class="collapse" data-bs-parent="<img src=1 onerror=alert(123)>" role="tabpanel" aria-labelledby="headingFour">
<div class="card-body">
Anim pariatur cliche reprehenderit, enim eiusmod high life accusamus terry richardson ad squid. 3 wolf moon officia aute, non cupidatat skateboard dolor brunch. Food truck quinoa nesciunt laborum eiusmod. Brunch 3 wolf moon tempor, sunt aliqua put a bird on it squid single-origin coffee nulla assumenda shoreditch et. Nihil anim keffiyeh helvetica, craft beer labore wes anderson cred nesciunt sapiente ea proident. Ad vegan excepteur butcher vice lomo. Leggings occaecat craft beer farm-to-table, raw denim aesthetic synth nesciunt you probably haven't heard of them accusamus labore sustainable VHS.
</div>
</div>
</div>
</div>
</div>
<script src="../../../dist/js/tabler.min.js"></script>
<script>window.bootstrap = window.tabler && window.tabler.bootstrap || {};</script>
</body>
</html>

View File

@@ -0,0 +1,206 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="../../../dist/css/tabler.min.css" rel="stylesheet">
<title>Dropdown</title>
</head>
<body>
<div class="container">
<h1>Dropdown <small>Bootstrap Visual Test</small></h1>
<nav class="navbar navbar-expand-md bg-light">
<a class="navbar-brand" href="#">Navbar</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarResponsive" aria-controls="navbarResponsive" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarResponsive">
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link active" href="#" aria-current="page">Home</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">Link</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">Link</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" data-bs-toggle="dropdown" aria-expanded="false">Dropdown</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="#">Action</a></li>
<li><a class="dropdown-item" href="#">Another action</a></li>
<li><a class="dropdown-item" href="#">Something else here</a></li>
</ul>
</li>
</ul>
</div>
</nav>
<ul class="nav nav-pills mt-3">
<li class="nav-item">
<a class="nav-link active" href="#">Active</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">Link</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">Link</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" data-bs-toggle="dropdown" aria-expanded="false">Dropdown</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="#">Action</a></li>
<li><a class="dropdown-item" href="#">Another action</a></li>
<li><a class="dropdown-item" href="#">Something else here</a></li>
</ul>
</li>
</ul>
<div class="row">
<div class="col-sm-12 mt-4">
<div class="dropdown">
<button type="button" class="btn btn-secondary" data-bs-toggle="dropdown" aria-expanded="false">Dropdown</button>
<div class="dropdown-menu">
<input id="textField" type="text">
</div>
</div>
<div class="btn-group dropup">
<button type="button" class="btn btn-secondary">Dropup split</button>
<button type="button" class="btn btn-secondary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
<span class="visually-hidden">Dropup split</span>
</button>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="#">Action</a></li>
<li><a class="dropdown-item" href="#">Another action</a></li>
<li><a class="dropdown-item" href="#">Something else here</a></li>
</ul>
</div>
</div>
<div class="col-sm-12 mt-4">
<div class="btn-group dropup">
<button type="button" class="btn btn-secondary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">Dropup</button>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="#">Action</a></li>
<li><a class="dropdown-item" href="#">Another action</a></li>
<li><a class="dropdown-item" href="#">Something else here</a></li>
</ul>
</div>
<div class="btn-group">
<button type="button" class="btn btn-secondary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
This dropdown's menu is end-aligned
</button>
<div class="dropdown-menu dropdown-menu-end">
<button class="dropdown-item" type="button">Action</button>
<button class="dropdown-item" type="button">Another action</button>
<button class="dropdown-item" type="button">Something else here</button>
</div>
</div>
</div>
<div class="col-sm-12 mt-4">
<div class="btn-group dropup">
<a href="#" class="btn btn-secondary">Dropup split align end</a>
<button type="button" id="dropdown-page-subheader-button-3" class="btn btn-secondary dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false">
<span class="visually-hidden">Product actions</span>
</button>
<div class="dropdown-menu dropdown-menu-end">
<button class="dropdown-item" type="button">Action</button>
<button class="dropdown-item" type="button">Another action</button>
<button class="dropdown-item" type="button">Something else here with a long text</button>
</div>
</div>
<div class="btn-group dropup">
<button type="button" class="btn btn-secondary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">Dropup align end</button>
<div class="dropdown-menu dropdown-menu-end">
<button class="dropdown-item" type="button">Action</button>
<button class="dropdown-item" type="button">Another action</button>
<button class="dropdown-item" type="button">Something else here with a long text</button>
</div>
</div>
</div>
<div class="col-sm-12 mt-4">
<div class="btn-group dropend">
<a href="#" class="btn btn-secondary">Dropend split</a>
<button type="button" id="dropdown-page-subheader-button-4" class="btn btn-secondary dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false">
<span class="visually-hidden">Product actions</span>
</button>
<div class="dropdown-menu">
<button class="dropdown-item" type="button">Action</button>
<button class="dropdown-item" type="button">Another action</button>
<button class="dropdown-item" type="button">Something else here with a long text</button>
</div>
</div>
<div class="btn-group dropend">
<button class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
Dropend
</button>
<div class="dropdown-menu">
<button class="dropdown-item" type="button">Action</button>
<button class="dropdown-item" type="button">Another action</button>
<button class="dropdown-item" type="button">Something else here</button>
</div>
</div>
<!-- dropstart -->
<div class="btn-group dropstart">
<a href="#" class="btn btn-secondary">Dropstart split</a>
<button type="button" id="dropdown-page-subheader-button-5" class="btn btn-secondary dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false">
<span class="visually-hidden">Product actions</span>
</button>
<div class="dropdown-menu">
<button class="dropdown-item" type="button">Action</button>
<button class="dropdown-item" type="button">Another action</button>
<button class="dropdown-item" type="button">Something else here with a long text</button>
</div>
</div>
<div class="btn-group dropstart">
<button class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
Dropstart
</button>
<div class="dropdown-menu">
<button class="dropdown-item" type="button">Action</button>
<button class="dropdown-item" type="button">Another action</button>
<button class="dropdown-item" type="button">Something else here</button>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-sm-3 mt-4">
<div class="btn-group dropdown">
<button type="button" class="btn btn-secondary">Dropdown reference</button>
<button type="button" class="btn btn-secondary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false" data-bs-reference="parent">
<span class="visually-hidden">Dropdown split</span>
</button>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="#">Action</a></li>
<li><a class="dropdown-item" href="#">Another action</a></li>
<li><a class="dropdown-item" href="#">Something else here</a></li>
</ul>
</div>
</div>
<div class="col-sm-3 mt-4">
<div class="dropdown">
<button class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" data-bs-display="static" aria-expanded="false">
Dropdown menu without Popper
</button>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="#">Action</a></li>
<li><a class="dropdown-item" href="#">Another action</a></li>
<li><a class="dropdown-item" href="#">Something else here</a></li>
</ul>
</div>
</div>
</div>
</div>
<script src="../../../dist/js/tabler.min.js"></script>
<script>window.bootstrap = window.tabler && window.tabler.bootstrap || {};</script>
</body>
</html>

View File

@@ -0,0 +1,280 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="../../../dist/css/tabler.min.css" rel="stylesheet">
<title>Modal</title>
<style>
#tall {
height: 1500px;
width: 100px;
}
</style>
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container-fluid">
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarResponsive" aria-controls="navbarResponsive" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarResponsive">
<a class="navbar-brand" href="#">This shouldn't jump!</a>
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link active" href="#" aria-current="page">Home</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">Link</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">Link</a>
</li>
</ul>
</div>
</div>
</nav>
<div class="container mt-3">
<h1>Modal <small>Bootstrap Visual Test</small></h1>
<div class="modal fade" id="myModal" tabindex="-1" aria-labelledby="myModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-4" id="myModalLabel">Modal title</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<h4>Text in a modal</h4>
<p>Duis mollis, est non commodo luctus, nisi erat porttitor ligula.</p>
<h4>Popover in a modal</h4>
<p>This <button type="button" class="btn btn-primary" data-bs-toggle="popover" data-bs-placement="left" title="Popover title" data-bs-content="And here's some amazing content. It's very engaging. Right?">button</button> should trigger a popover on click.</p>
<h4>Tooltips in a modal</h4>
<p><a href="#" data-bs-toggle="tooltip" data-bs-placement="top" title="Tooltip on top">This link</a> and <a href="#" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Tooltip on bottom">that link</a> should have tooltips on hover.</p>
<div id="accordion" role="tablist">
<div class="card" role="presentation">
<div class="card-header" role="tab" id="headingOne">
<h5 class="mb-0">
<a data-bs-toggle="collapse" href="#collapseOne" aria-expanded="true" aria-controls="collapseOne">
Collapsible Group Item #1
</a>
</h5>
</div>
<div id="collapseOne" class="collapse show" data-bs-parent="#accordion" role="tabpanel" aria-labelledby="headingOne">
<div class="card-body">
Anim pariatur cliche reprehenderit, enim eiusmod high life accusamus terry richardson ad squid. 3 wolf moon officia aute, non cupidatat skateboard dolor brunch. Food truck quinoa nesciunt laborum eiusmod. Brunch 3 wolf moon tempor, sunt aliqua put a bird on it squid single-origin coffee nulla assumenda shoreditch et. Nihil anim keffiyeh helvetica, craft beer labore wes anderson cred nesciunt sapiente ea proident. Ad vegan excepteur butcher vice lomo. Leggings occaecat craft beer farm-to-table, raw denim aesthetic synth nesciunt you probably haven't heard of them accusamus labore sustainable VHS.
</div>
</div>
</div>
<div class="card" role="presentation">
<div class="card-header" role="tab" id="headingTwo">
<h5 class="mb-0">
<a class="collapsed" data-bs-toggle="collapse" href="#collapseTwo" aria-expanded="false" aria-controls="collapseTwo">
Collapsible Group Item #2
</a>
</h5>
</div>
<div id="collapseTwo" class="collapse" data-bs-parent="#accordion" role="tabpanel" aria-labelledby="headingTwo">
<div class="card-body">
Anim pariatur cliche reprehenderit, enim eiusmod high life accusamus terry richardson ad squid. 3 wolf moon officia aute, non cupidatat skateboard dolor brunch. Food truck quinoa nesciunt laborum eiusmod. Brunch 3 wolf moon tempor, sunt aliqua put a bird on it squid single-origin coffee nulla assumenda shoreditch et. Nihil anim keffiyeh helvetica, craft beer labore wes anderson cred nesciunt sapiente ea proident. Ad vegan excepteur butcher vice lomo. Leggings occaecat craft beer farm-to-table, raw denim aesthetic synth nesciunt you probably haven't heard of them accusamus labore sustainable VHS.
</div>
</div>
</div>
<div class="card" role="presentation">
<div class="card-header" role="tab" id="headingThree">
<h5 class="mb-0">
<a class="collapsed" data-bs-toggle="collapse" href="#collapseThree" aria-expanded="false" aria-controls="collapseThree">
Collapsible Group Item #3
</a>
</h5>
</div>
<div id="collapseThree" class="collapse" data-bs-parent="#accordion" role="tabpanel" aria-labelledby="headingThree">
<div class="card-body">
Anim pariatur cliche reprehenderit, enim eiusmod high life accusamus terry richardson ad squid. 3 wolf moon officia aute, non cupidatat skateboard dolor brunch. Food truck quinoa nesciunt laborum eiusmod. Brunch 3 wolf moon tempor, sunt aliqua put a bird on it squid single-origin coffee nulla assumenda shoreditch et. Nihil anim keffiyeh helvetica, craft beer labore wes anderson cred nesciunt sapiente ea proident. Ad vegan excepteur butcher vice lomo. Leggings occaecat craft beer farm-to-table, raw denim aesthetic synth nesciunt you probably haven't heard of them accusamus labore sustainable VHS.
</div>
</div>
</div>
</div>
<hr>
<h4>Overflowing text to show scroll behavior</h4>
<p>Cras mattis consectetur purus sit amet fermentum. Cras justo odio, dapibus ac facilisis in, egestas eget quam. Morbi leo risus, porta ac consectetur ac, vestibulum at eros.</p>
<p>Praesent commodo cursus magna, vel scelerisque nisl consectetur et. Vivamus sagittis lacus vel augue laoreet rutrum faucibus dolor auctor.</p>
<p>Aenean lacinia bibendum nulla sed consectetur. Praesent commodo cursus magna, vel scelerisque nisl consectetur et. Donec sed odio dui. Donec ullamcorper nulla non metus auctor fringilla.</p>
<p>Cras mattis consectetur purus sit amet fermentum. Cras justo odio, dapibus ac facilisis in, egestas eget quam. Morbi leo risus, porta ac consectetur ac, vestibulum at eros.</p>
<p>Praesent commodo cursus magna, vel scelerisque nisl consectetur et. Vivamus sagittis lacus vel augue laoreet rutrum faucibus dolor auctor.</p>
<p>Aenean lacinia bibendum nulla sed consectetur. Praesent commodo cursus magna, vel scelerisque nisl consectetur et. Donec sed odio dui. Donec ullamcorper nulla non metus auctor fringilla.</p>
<p>Cras mattis consectetur purus sit amet fermentum. Cras justo odio, dapibus ac facilisis in, egestas eget quam. Morbi leo risus, porta ac consectetur ac, vestibulum at eros.</p>
<p>Praesent commodo cursus magna, vel scelerisque nisl consectetur et. Vivamus sagittis lacus vel augue laoreet rutrum faucibus dolor auctor.</p>
<p>Aenean lacinia bibendum nulla sed consectetur. Praesent commodo cursus magna, vel scelerisque nisl consectetur et. Donec sed odio dui. Donec ullamcorper nulla non metus auctor fringilla.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-primary">Save changes</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="firefoxModal" tabindex="-1" aria-labelledby="firefoxModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-4" id="firefoxModalLabel">Firefox Bug Test</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<ol>
<li>Ensure you're using Firefox.</li>
<li>Open a new tab and then switch back to this tab.</li>
<li>Click into this input: <input type="text" id="ff-bug-input"></li>
<li>Switch to the other tab and then back to this tab.</li>
</ol>
<p>Test result: <strong id="ff-bug-test-result"></strong></p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-primary">Save changes</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="slowModal" tabindex="-1" aria-labelledby="slowModalLabel" aria-hidden="true" style="transition-duration: 5s;">
<div class="modal-dialog" style="transition-duration: inherit;">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-4" id="slowModalLabel">Lorem slowly</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Duis mollis, est non commodo luctus, nisi erat porttitor ligula, eget lacinia odio sem nec elit. Donec sed odio dui. Nullam quis risus eget urna mollis ornare vel eu leo. Nulla vitae elit libero, a pharetra augue.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-primary">Save changes</button>
</div>
</div>
</div>
</div>
<button type="button" class="btn btn-primary btn-lg" data-bs-toggle="modal" data-bs-target="#myModal">
Launch demo modal
</button>
<button type="button" class="btn btn-primary btn-lg" id="tall-toggle">
Toggle tall &lt;body&gt; content
</button>
<br><br>
<button type="button" class="btn btn-secondary btn-lg" data-bs-toggle="modal" data-bs-target="#firefoxModal">
Launch Firefox bug test modal
</button>
(<a href="https://github.com/twbs/bootstrap/issues/18365">See Issue #18365</a>)
<br><br>
<button type="button" class="btn btn-secondary btn-lg" data-bs-toggle="modal" data-bs-target="#slowModal">
Launch modal with slow transition
</button>
<br><br>
<div class="text-bg-dark p-2" id="tall" style="display: none;">
Tall body content to force the page to have a scrollbar.
</div>
<button type="button" class="btn btn-secondary btn-lg" data-bs-toggle="modal" data-bs-target="&#x3C;div class=&#x22;modal fade the-bad&#x22; tabindex=&#x22;-1&#x22;&#x3E;&#x3C;div class=&#x22;modal-dialog&#x22;&#x3E;&#x3C;div class=&#x22;modal-content&#x22;&#x3E;&#x3C;div class=&#x22;modal-header&#x22;&#x3E;&#x3C;button type=&#x22;button&#x22; class=&#x22;btn-close&#x22; data-bs-dismiss=&#x22;modal&#x22; aria-label=&#x22;Close&#x22;&#x3E;&#x3C;span aria-hidden=&#x22;true&#x22;&#x3E;&#x26;times;&#x3C;/span&#x3E;&#x3C;/button&#x3E;&#x3C;h1 class=&#x22;modal-title fs-4&#x22;&#x3E;The Bad Modal&#x3C;/h1&#x3E;&#x3C;/div&#x3E;&#x3C;div class=&#x22;modal-body&#x22;&#x3E;This modal&#x27;s HTTML source code is declared inline, inside the data-bs-target attribute of it&#x27;s show-button&#x3C;/div&#x3E;&#x3C;/div&#x3E;&#x3C;/div&#x3E;&#x3C;/div&#x3E;">
Modal with an XSS inside the data-bs-target
</button>
<br><br>
<button type="button" class="btn btn-secondary btn-lg" id="btnPreventModal">
Launch prevented modal on hide (to see the result open your console)
</button>
</div>
<script src="../../../dist/js/tabler.min.js"></script>
<script>window.bootstrap = window.tabler && window.tabler.bootstrap || {};</script>
<script>
/* global bootstrap: false */
const ffBugTestResult = document.getElementById('ff-bug-test-result')
const firefoxTestDone = false
function reportFirefoxTestResult(result) {
if (!firefoxTestDone) {
ffBugTestResult.classList.add(result ? 'text-success' : 'text-danger')
ffBugTestResult.textContent = result ? 'PASS' : 'FAIL'
}
}
document.querySelectorAll('[data-bs-toggle="popover"]').forEach(popoverEl => new bootstrap.Popover(popoverEl))
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(tooltipEl => new bootstrap.Tooltip(tooltipEl))
const tall = document.getElementById('tall')
document.getElementById('tall-toggle').addEventListener('click', () => {
tall.style.display = tall.style.display === 'none' ? 'block' : 'none'
})
const ffBugInput = document.getElementById('ff-bug-input')
const firefoxModal = document.getElementById('firefoxModal')
function handlerClickFfBugInput() {
firefoxModal.addEventListener('focus', reportFirefoxTestResult.bind(false))
ffBugInput.addEventListener('focus', reportFirefoxTestResult.bind(true))
ffBugInput.removeEventListener('focus', handlerClickFfBugInput)
}
ffBugInput.addEventListener('focus', handlerClickFfBugInput)
const modalFf = new bootstrap.Modal(firefoxModal)
document.getElementById('btnPreventModal').addEventListener('click', () => {
const shownFirefoxModal = () => {
modalFf.hide()
firefoxModal.removeEventListener('shown.bs.modal', hideFirefoxModal)
}
const hideFirefoxModal = event => {
event.preventDefault()
firefoxModal.removeEventListener('hide.bs.modal', hideFirefoxModal)
if (modalFf._isTransitioning) {
console.error('Modal plugin should not set _isTransitioning when hide event is prevented')
} else {
console.log('Test passed')
modalFf.hide() // work as expected
}
}
firefoxModal.addEventListener('shown.bs.modal', shownFirefoxModal)
firefoxModal.addEventListener('hide.bs.modal', hideFirefoxModal)
modalFf.show()
})
// Test transition duration
let t0
let t1
const slowModal = document.getElementById('slowModal')
slowModal.addEventListener('shown.bs.modal', () => {
t1 = performance.now()
console.log(`transition-duration took ${t1 - t0}ms.`)
})
slowModal.addEventListener('show.bs.modal', () => {
t0 = performance.now()
})
</script>
</body>
</html>

View File

@@ -0,0 +1,31 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="../../../dist/css/tabler.min.css" rel="stylesheet">
<title>Offcanvas</title>
</head>
<body>
<div class="container py-4">
<h1>Offcanvas <small>Tabler Visual Test</small></h1>
<button class="btn btn-primary" type="button" data-bs-toggle="offcanvas" data-bs-target="#offcanvasExample" aria-controls="offcanvasExample">
Toggle offcanvas
</button>
<div class="offcanvas offcanvas-start" tabindex="-1" id="offcanvasExample" aria-labelledby="offcanvasExampleLabel">
<div class="offcanvas-header">
<h5 class="offcanvas-title" id="offcanvasExampleLabel">Offcanvas</h5>
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>
</div>
<div class="offcanvas-body">
<p>Content for the offcanvas. You can use <code>data-bs-toggle="offcanvas"</code> or <code>data-tblr-toggle="offcanvas"</code>.</p>
</div>
</div>
</div>
<script src="../../../dist/js/tabler.min.js"></script>
<script>window.bootstrap = window.tabler && window.tabler.bootstrap || {};</script>
</body>
</html>

View File

@@ -0,0 +1,42 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="../../../dist/css/tabler.min.css" rel="stylesheet">
<title>Popover</title>
</head>
<body>
<div class="container">
<h1>Popover <small>Bootstrap Visual Test</small></h1>
<button type="button" class="btn btn-secondary" data-bs-container="body" data-bs-toggle="popover" data-bs-placement="auto" data-bs-content="Vivamus sagittis lacus vel augue laoreet rutrum faucibus.">
Popover on auto
</button>
<button type="button" class="btn btn-secondary" data-bs-container="body" data-bs-toggle="popover" data-bs-placement="top" data-bs-content="Default placement was on top but not enough place">
Popover on top
</button>
<button type="button" class="btn btn-secondary" data-bs-container="body" data-bs-toggle="popover" data-bs-placement="right" data-bs-content="Vivamus sagittis lacus vel augue laoreet rutrum faucibus.">
Popover on end
</button>
<button type="button" class="btn btn-secondary" data-bs-container="body" data-bs-toggle="popover" data-bs-placement="bottom" data-bs-content="Vivamus sagittis lacus vel augue laoreet rutrum faucibus.">
Popover on bottom
</button>
<button type="button" class="btn btn-secondary" data-bs-container="body" data-bs-toggle="popover" data-bs-placement="left" data-bs-content="Vivamus sagittis lacus vel augue laoreet rutrum faucibus.">
Popover on start
</button>
</div>
<script src="../../../dist/js/tabler.min.js"></script>
<script>window.bootstrap = window.tabler && window.tabler.bootstrap || {};</script>
<script>
/* global bootstrap: false */
document.querySelectorAll('[data-bs-toggle="popover"]').forEach(popoverEl => new bootstrap.Popover(popoverEl))
</script>
</body>
</html>

View File

@@ -0,0 +1,101 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="../../../dist/css/tabler.min.css" rel="stylesheet">
<title>Scrollspy</title>
<style>
body { padding-top: 70px; }
</style>
</head>
<body data-bs-spy="scroll" data-bs-target=".navbar" data-bs-offset="70">
<nav class="navbar navbar-expand-md navbar-dark bg-dark fixed-top">
<a class="navbar-brand" href="#">Scrollspy test</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="navbar-collapse collapse" id="navbarSupportedContent">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link" href="#fat">@fat</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#mdo">@mdo</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" data-bs-toggle="dropdown" aria-expanded="false">Dropdown</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="#one">One</a></li>
<li><a class="dropdown-item" href="#two">Two</a></li>
<li><a class="dropdown-item" href="#three">Three</a></li>
<li><a class="dropdown-item" href="#présentation">Présentation</a></li>
</ul>
</li>
<li class="nav-item">
<a class="nav-link" href="#final">Final</a>
</li>
</ul>
</div>
</nav>
<div class="container">
<h2 id="fat">@fat</h2>
<p>Ad leggings keytar, brunch id art party dolor labore. Pitchfork yr enim lo-fi before they sold out qui. Tumblr farm-to-table bicycle rights whatever. Anim keffiyeh carles cardigan. Velit seitan mcsweeney's photo booth 3 wolf moon irure. Cosby sweater lomo jean shorts, williamsburg hoodie minim qui you probably haven't heard of them et cardigan trust fund culpa biodiesel wes anderson aesthetic. Nihil tattooed accusamus, cred irony biodiesel keffiyeh artisan ullamco consequat.</p>
<p>Ad leggings keytar, brunch id art party dolor labore. Pitchfork yr enim lo-fi before they sold out qui. Tumblr farm-to-table bicycle rights whatever. Anim keffiyeh carles cardigan. Velit seitan mcsweeney's photo booth 3 wolf moon irure. Cosby sweater lomo jean shorts, williamsburg hoodie minim qui you probably haven't heard of them et cardigan trust fund culpa biodiesel wes anderson aesthetic. Nihil tattooed accusamus, cred irony biodiesel keffiyeh artisan ullamco consequat.</p>
<p>Ad leggings keytar, brunch id art party dolor labore. Pitchfork yr enim lo-fi before they sold out qui. Tumblr farm-to-table bicycle rights whatever. Anim keffiyeh carles cardigan. Velit seitan mcsweeney's photo booth 3 wolf moon irure. Cosby sweater lomo jean shorts, williamsburg hoodie minim qui you probably haven't heard of them et cardigan trust fund culpa biodiesel wes anderson aesthetic. Nihil tattooed accusamus, cred irony biodiesel keffiyeh artisan ullamco consequat.</p>
<p>Ad leggings keytar, brunch id art party dolor labore. Pitchfork yr enim lo-fi before they sold out qui. Tumblr farm-to-table bicycle rights whatever. Anim keffiyeh carles cardigan. Velit seitan mcsweeney's photo booth 3 wolf moon irure. Cosby sweater lomo jean shorts, williamsburg hoodie minim qui you probably haven't heard of them et cardigan trust fund culpa biodiesel wes anderson aesthetic. Nihil tattooed accusamus, cred irony biodiesel keffiyeh artisan ullamco consequat.</p>
<p>Ad leggings keytar, brunch id art party dolor labore. Pitchfork yr enim lo-fi before they sold out qui. Tumblr farm-to-table bicycle rights whatever. Anim keffiyeh carles cardigan. Velit seitan mcsweeney's photo booth 3 wolf moon irure. Cosby sweater lomo jean shorts, williamsburg hoodie minim qui you probably haven't heard of them et cardigan trust fund culpa biodiesel wes anderson aesthetic. Nihil tattooed accusamus, cred irony biodiesel keffiyeh artisan ullamco consequat.</p>
<hr>
<h2 id="mdo">@mdo</h2>
<p>Veniam marfa mustache skateboard, adipisicing fugiat velit pitchfork beard. Freegan beard aliqua cupidatat mcsweeney's vero. Cupidatat four loko nisi, ea helvetica nulla carles. Tattooed cosby sweater food truck, mcsweeney's quis non freegan vinyl. Lo-fi wes anderson +1 sartorial. Carles non aesthetic exercitation quis gentrify. Brooklyn adipisicing craft beer vice keytar deserunt.</p>
<p>Ad leggings keytar, brunch id art party dolor labore. Pitchfork yr enim lo-fi before they sold out qui. Tumblr farm-to-table bicycle rights whatever. Anim keffiyeh carles cardigan. Velit seitan mcsweeney's photo booth 3 wolf moon irure. Cosby sweater lomo jean shorts, williamsburg hoodie minim qui you probably haven't heard of them et cardigan trust fund culpa biodiesel wes anderson aesthetic. Nihil tattooed accusamus, cred irony biodiesel keffiyeh artisan ullamco consequat.</p>
<p>Ad leggings keytar, brunch id art party dolor labore. Pitchfork yr enim lo-fi before they sold out qui. Tumblr farm-to-table bicycle rights whatever. Anim keffiyeh carles cardigan. Velit seitan mcsweeney's photo booth 3 wolf moon irure. Cosby sweater lomo jean shorts, williamsburg hoodie minim qui you probably haven't heard of them et cardigan trust fund culpa biodiesel wes anderson aesthetic. Nihil tattooed accusamus, cred irony biodiesel keffiyeh artisan ullamco consequat.</p>
<p>Ad leggings keytar, brunch id art party dolor labore. Pitchfork yr enim lo-fi before they sold out qui. Tumblr farm-to-table bicycle rights whatever. Anim keffiyeh carles cardigan. Velit seitan mcsweeney's photo booth 3 wolf moon irure. Cosby sweater lomo jean shorts, williamsburg hoodie minim qui you probably haven't heard of them et cardigan trust fund culpa biodiesel wes anderson aesthetic. Nihil tattooed accusamus, cred irony biodiesel keffiyeh artisan ullamco consequat.</p>
<p>Ad leggings keytar, brunch id art party dolor labore. Pitchfork yr enim lo-fi before they sold out qui. Tumblr farm-to-table bicycle rights whatever. Anim keffiyeh carles cardigan. Velit seitan mcsweeney's photo booth 3 wolf moon irure. Cosby sweater lomo jean shorts, williamsburg hoodie minim qui you probably haven't heard of them et cardigan trust fund culpa biodiesel wes anderson aesthetic. Nihil tattooed accusamus, cred irony biodiesel keffiyeh artisan ullamco consequat.</p>
<hr>
<h2 id="one">one</h2>
<p>Occaecat commodo aliqua delectus. Fap craft beer deserunt skateboard ea. Lomo bicycle rights adipisicing banh mi, velit ea sunt next level locavore single-origin coffee in magna veniam. High life id vinyl, echo park consequat quis aliquip banh mi pitchfork. Vero VHS est adipisicing. Consectetur nisi DIY minim messenger bag. Cred ex in, sustainable delectus consectetur fanny pack iphone.</p>
<p>Ad leggings keytar, brunch id art party dolor labore. Pitchfork yr enim lo-fi before they sold out qui. Tumblr farm-to-table bicycle rights whatever. Anim keffiyeh carles cardigan. Velit seitan mcsweeney's photo booth 3 wolf moon irure. Cosby sweater lomo jean shorts, williamsburg hoodie minim qui you probably haven't heard of them et cardigan trust fund culpa biodiesel wes anderson aesthetic. Nihil tattooed accusamus, cred irony biodiesel keffiyeh artisan ullamco consequat.</p>
<p>Ad leggings keytar, brunch id art party dolor labore. Pitchfork yr enim lo-fi before they sold out qui. Tumblr farm-to-table bicycle rights whatever. Anim keffiyeh carles cardigan. Velit seitan mcsweeney's photo booth 3 wolf moon irure. Cosby sweater lomo jean shorts, williamsburg hoodie minim qui you probably haven't heard of them et cardigan trust fund culpa biodiesel wes anderson aesthetic. Nihil tattooed accusamus, cred irony biodiesel keffiyeh artisan ullamco consequat.</p>
<p>Ad leggings keytar, brunch id art party dolor labore. Pitchfork yr enim lo-fi before they sold out qui. Tumblr farm-to-table bicycle rights whatever. Anim keffiyeh carles cardigan. Velit seitan mcsweeney's photo booth 3 wolf moon irure. Cosby sweater lomo jean shorts, williamsburg hoodie minim qui you probably haven't heard of them et cardigan trust fund culpa biodiesel wes anderson aesthetic. Nihil tattooed accusamus, cred irony biodiesel keffiyeh artisan ullamco consequat.</p>
<p>Ad leggings keytar, brunch id art party dolor labore. Pitchfork yr enim lo-fi before they sold out qui. Tumblr farm-to-table bicycle rights whatever. Anim keffiyeh carles cardigan. Velit seitan mcsweeney's photo booth 3 wolf moon irure. Cosby sweater lomo jean shorts, williamsburg hoodie minim qui you probably haven't heard of them et cardigan trust fund culpa biodiesel wes anderson aesthetic. Nihil tattooed accusamus, cred irony biodiesel keffiyeh artisan ullamco consequat.</p>
<hr>
<h2 id="two">two</h2>
<p>In incididunt echo park, officia deserunt mcsweeney's proident master cleanse thundercats sapiente veniam. Excepteur VHS elit, proident shoreditch +1 biodiesel laborum craft beer. Single-origin coffee wayfarers irure four loko, cupidatat terry richardson master cleanse. Assumenda you probably haven't heard of them art party fanny pack, tattooed nulla cardigan tempor ad. Proident wolf nesciunt sartorial keffiyeh eu banh mi sustainable. Elit wolf voluptate, lo-fi ea portland before they sold out four loko. Locavore enim nostrud mlkshk brooklyn nesciunt.</p>
<p>Ad leggings keytar, brunch id art party dolor labore. Pitchfork yr enim lo-fi before they sold out qui. Tumblr farm-to-table bicycle rights whatever. Anim keffiyeh carles cardigan. Velit seitan mcsweeney's photo booth 3 wolf moon irure. Cosby sweater lomo jean shorts, williamsburg hoodie minim qui you probably haven't heard of them et cardigan trust fund culpa biodiesel wes anderson aesthetic. Nihil tattooed accusamus, cred irony biodiesel keffiyeh artisan ullamco consequat.</p>
<p>Ad leggings keytar, brunch id art party dolor labore. Pitchfork yr enim lo-fi before they sold out qui. Tumblr farm-to-table bicycle rights whatever. Anim keffiyeh carles cardigan. Velit seitan mcsweeney's photo booth 3 wolf moon irure. Cosby sweater lomo jean shorts, williamsburg hoodie minim qui you probably haven't heard of them et cardigan trust fund culpa biodiesel wes anderson aesthetic. Nihil tattooed accusamus, cred irony biodiesel keffiyeh artisan ullamco consequat.</p>
<p>Ad leggings keytar, brunch id art party dolor labore. Pitchfork yr enim lo-fi before they sold out qui. Tumblr farm-to-table bicycle rights whatever. Anim keffiyeh carles cardigan. Velit seitan mcsweeney's photo booth 3 wolf moon irure. Cosby sweater lomo jean shorts, williamsburg hoodie minim qui you probably haven't heard of them et cardigan trust fund culpa biodiesel wes anderson aesthetic. Nihil tattooed accusamus, cred irony biodiesel keffiyeh artisan ullamco consequat.</p>
<p>Ad leggings keytar, brunch id art party dolor labore. Pitchfork yr enim lo-fi before they sold out qui. Tumblr farm-to-table bicycle rights whatever. Anim keffiyeh carles cardigan. Velit seitan mcsweeney's photo booth 3 wolf moon irure. Cosby sweater lomo jean shorts, williamsburg hoodie minim qui you probably haven't heard of them et cardigan trust fund culpa biodiesel wes anderson aesthetic. Nihil tattooed accusamus, cred irony biodiesel keffiyeh artisan ullamco consequat.</p>
<hr>
<h2 id="three">three</h2>
<p>Ad leggings keytar, brunch id art party dolor labore. Pitchfork yr enim lo-fi before they sold out qui. Tumblr farm-to-table bicycle rights whatever. Anim keffiyeh carles cardigan. Velit seitan mcsweeney's photo booth 3 wolf moon irure. Cosby sweater lomo jean shorts, williamsburg hoodie minim qui you probably haven't heard of them et cardigan trust fund culpa biodiesel wes anderson aesthetic. Nihil tattooed accusamus, cred irony biodiesel keffiyeh artisan ullamco consequat.</p>
<p>Keytar twee blog, culpa messenger bag marfa whatever delectus food truck. Sapiente synth id assumenda. Locavore sed helvetica cliche irony, thundercats you probably haven't heard of them consequat hoodie gluten-free lo-fi fap aliquip. Labore elit placeat before they sold out, terry richardson proident brunch nesciunt quis cosby sweater pariatur keffiyeh ut helvetica artisan. Cardigan craft beer seitan readymade velit. VHS chambray laboris tempor veniam. Anim mollit minim commodo ullamco thundercats.</p>
<p>Ad leggings keytar, brunch id art party dolor labore. Pitchfork yr enim lo-fi before they sold out qui. Tumblr farm-to-table bicycle rights whatever. Anim keffiyeh carles cardigan. Velit seitan mcsweeney's photo booth 3 wolf moon irure. Cosby sweater lomo jean shorts, williamsburg hoodie minim qui you probably haven't heard of them et cardigan trust fund culpa biodiesel wes anderson aesthetic. Nihil tattooed accusamus, cred irony biodiesel keffiyeh artisan ullamco consequat.</p>
<p>Ad leggings keytar, brunch id art party dolor labore. Pitchfork yr enim lo-fi before they sold out qui. Tumblr farm-to-table bicycle rights whatever. Anim keffiyeh carles cardigan. Velit seitan mcsweeney's photo booth 3 wolf moon irure. Cosby sweater lomo jean shorts, williamsburg hoodie minim qui you probably haven't heard of them et cardigan trust fund culpa biodiesel wes anderson aesthetic. Nihil tattooed accusamus, cred irony biodiesel keffiyeh artisan ullamco consequat.</p>
<p>Ad leggings keytar, brunch id art party dolor labore. Pitchfork yr enim lo-fi before they sold out qui. Tumblr farm-to-table bicycle rights whatever. Anim keffiyeh carles cardigan. Velit seitan mcsweeney's photo booth 3 wolf moon irure. Cosby sweater lomo jean shorts, williamsburg hoodie minim qui you probably haven't heard of them et cardigan trust fund culpa biodiesel wes anderson aesthetic. Nihil tattooed accusamus, cred irony biodiesel keffiyeh artisan ullamco consequat.</p>
<p>Ad leggings keytar, brunch id art party dolor labore. Pitchfork yr enim lo-fi before they sold out qui. Tumblr farm-to-table bicycle rights whatever. Anim keffiyeh carles cardigan. Velit seitan mcsweeney's photo booth 3 wolf moon irure. Cosby sweater lomo jean shorts, williamsburg hoodie minim qui you probably haven't heard of them et cardigan trust fund culpa biodiesel wes anderson aesthetic. Nihil tattooed accusamus, cred irony biodiesel keffiyeh artisan ullamco consequat.</p>
<p>Ad leggings keytar, brunch id art party dolor labore. Pitchfork yr enim lo-fi before they sold out qui. Tumblr farm-to-table bicycle rights whatever. Anim keffiyeh carles cardigan. Velit seitan mcsweeney's photo booth 3 wolf moon irure. Cosby sweater lomo jean shorts, williamsburg hoodie minim qui you probably haven't heard of them et cardigan trust fund culpa biodiesel wes anderson aesthetic. Nihil tattooed accusamus, cred irony biodiesel keffiyeh artisan ullamco consequat.</p>
<p>Ad leggings keytar, brunch id art party dolor labore. Pitchfork yr enim lo-fi before they sold out qui. Tumblr farm-to-table bicycle rights whatever. Anim keffiyeh carles cardigan. Velit seitan mcsweeney's photo booth 3 wolf moon irure. Cosby sweater lomo jean shorts, williamsburg hoodie minim qui you probably haven't heard of them et cardigan trust fund culpa biodiesel wes anderson aesthetic. Nihil tattooed accusamus, cred irony biodiesel keffiyeh artisan ullamco consequat.</p>
<p>Ad leggings keytar, brunch id art party dolor labore. Pitchfork yr enim lo-fi before they sold out qui. Tumblr farm-to-table bicycle rights whatever. Anim keffiyeh carles cardigan. Velit seitan mcsweeney's photo booth 3 wolf moon irure. Cosby sweater lomo jean shorts, williamsburg hoodie minim qui you probably haven't heard of them et cardigan trust fund culpa biodiesel wes anderson aesthetic. Nihil tattooed accusamus, cred irony biodiesel keffiyeh artisan ullamco consequat.</p>
<p>Ad leggings keytar, brunch id art party dolor labore. Pitchfork yr enim lo-fi before they sold out qui. Tumblr farm-to-table bicycle rights whatever. Anim keffiyeh carles cardigan. Velit seitan mcsweeney's photo booth 3 wolf moon irure. Cosby sweater lomo jean shorts, williamsburg hoodie minim qui you probably haven't heard of them et cardigan trust fund culpa biodiesel wes anderson aesthetic. Nihil tattooed accusamus, cred irony biodiesel keffiyeh artisan ullamco consequat.</p>
<p>Ad leggings keytar, brunch id art party dolor labore. Pitchfork yr enim lo-fi before they sold out qui. Tumblr farm-to-table bicycle rights whatever. Anim keffiyeh carles cardigan. Velit seitan mcsweeney's photo booth 3 wolf moon irure. Cosby sweater lomo jean shorts, williamsburg hoodie minim qui you probably haven't heard of them et cardigan trust fund culpa biodiesel wes anderson aesthetic. Nihil tattooed accusamus, cred irony biodiesel keffiyeh artisan ullamco consequat.</p>
<p>Ad leggings keytar, brunch id art party dolor labore. Pitchfork yr enim lo-fi before they sold out qui. Tumblr farm-to-table bicycle rights whatever. Anim keffiyeh carles cardigan. Velit seitan mcsweeney's photo booth 3 wolf moon irure. Cosby sweater lomo jean shorts, williamsburg hoodie minim qui you probably haven't heard of them et cardigan trust fund culpa biodiesel wes anderson aesthetic. Nihil tattooed accusamus, cred irony biodiesel keffiyeh artisan ullamco consequat.</p>
<p>Ad leggings keytar, brunch id art party dolor labore. Pitchfork yr enim lo-fi before they sold out qui. Tumblr farm-to-table bicycle rights whatever. Anim keffiyeh carles cardigan. Velit seitan mcsweeney's photo booth 3 wolf moon irure. Cosby sweater lomo jean shorts, williamsburg hoodie minim qui you probably haven't heard of them et cardigan trust fund culpa biodiesel wes anderson aesthetic. Nihil tattooed accusamus, cred irony biodiesel keffiyeh artisan ullamco consequat.</p>
<p>Ad leggings keytar, brunch id art party dolor labore. Pitchfork yr enim lo-fi before they sold out qui. Tumblr farm-to-table bicycle rights whatever. Anim keffiyeh carles cardigan. Velit seitan mcsweeney's photo booth 3 wolf moon irure. Cosby sweater lomo jean shorts, williamsburg hoodie minim qui you probably haven't heard of them et cardigan trust fund culpa biodiesel wes anderson aesthetic. Nihil tattooed accusamus, cred irony biodiesel keffiyeh artisan ullamco consequat.</p>
<hr>
<h2 id="présentation">Présentation</h2>
<p>Ad leggings keytar, brunch id art party dolor labore. Pitchfork yr enim lo-fi before they sold out qui. Tumblr farm-to-table bicycle rights whatever. Anim keffiyeh carles cardigan. Velit seitan mcsweeney's photo booth 3 wolf moon irure. Cosby sweater lomo jean shorts, williamsburg hoodie minim qui you probably haven't heard of them et cardigan trust fund culpa biodiesel wes anderson aesthetic. Nihil tattooed accusamus, cred irony biodiesel keffiyeh artisan ullamco consequat.</p>
<p>Ad leggings keytar, brunch id art party dolor labore. Pitchfork yr enim lo-fi before they sold out qui. Tumblr farm-to-table bicycle rights whatever. Anim keffiyeh carles cardigan. Velit seitan mcsweeney's photo booth 3 wolf moon irure. Cosby sweater lomo jean shorts, williamsburg hoodie minim qui you probably haven't heard of them et cardigan trust fund culpa biodiesel wes anderson aesthetic. Nihil tattooed accusamus, cred irony biodiesel keffiyeh artisan ullamco consequat.</p>
<p>Ad leggings keytar, brunch id art party dolor labore. Pitchfork yr enim lo-fi before they sold out qui. Tumblr farm-to-table bicycle rights whatever. Anim keffiyeh carles cardigan. Velit seitan mcsweeney's photo booth 3 wolf moon irure. Cosby sweater lomo jean shorts, williamsburg hoodie minim qui you probably haven't heard of them et cardigan trust fund culpa biodiesel wes anderson aesthetic. Nihil tattooed accusamus, cred irony biodiesel keffiyeh artisan ullamco consequat.</p>
<p>Ad leggings keytar, brunch id art party dolor labore. Pitchfork yr enim lo-fi before they sold out qui. Tumblr farm-to-table bicycle rights whatever. Anim keffiyeh carles cardigan. Velit seitan mcsweeney's photo booth 3 wolf moon irure. Cosby sweater lomo jean shorts, williamsburg hoodie minim qui you probably haven't heard of them et cardigan trust fund culpa biodiesel wes anderson aesthetic. Nihil tattooed accusamus, cred irony biodiesel keffiyeh artisan ullamco consequat.</p>
<p>Ad leggings keytar, brunch id art party dolor labore. Pitchfork yr enim lo-fi before they sold out qui. Tumblr farm-to-table bicycle rights whatever. Anim keffiyeh carles cardigan. Velit seitan mcsweeney's photo booth 3 wolf moon irure. Cosby sweater lomo jean shorts, williamsburg hoodie minim qui you probably haven't heard of them et cardigan trust fund culpa biodiesel wes anderson aesthetic. Nihil tattooed accusamus, cred irony biodiesel keffiyeh artisan ullamco consequat.</p>
<p>Ad leggings keytar, brunch id art party dolor labore. Pitchfork yr enim lo-fi before they sold out qui. Tumblr farm-to-table bicycle rights whatever. Anim keffiyeh carles cardigan. Velit seitan mcsweeney's photo booth 3 wolf moon irure. Cosby sweater lomo jean shorts, williamsburg hoodie minim qui you probably haven't heard of them et cardigan trust fund culpa biodiesel wes anderson aesthetic. Nihil tattooed accusamus, cred irony biodiesel keffiyeh artisan ullamco consequat.</p>
<hr>
<h2 id="final">Final section</h2>
<p>Ad leggings keytar, brunch id art party dolor labore.</p>
</div>
<script src="../../../dist/js/tabler.min.js"></script>
<script>window.bootstrap = window.tabler && window.tabler.bootstrap || {};</script>
</body>
</html>

View File

@@ -0,0 +1,224 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="../../../dist/css/tabler.min.css" rel="stylesheet">
<title>Tab</title>
<style>
h4 {
margin: 40px 0 10px;
}
</style>
</head>
<body>
<div class="container">
<h1>Tab <small>Bootstrap Visual Test</small></h1>
<h4>Tabs without fade</h4>
<ul class="nav nav-tabs" role="tablist">
<li class="nav-item" role="presentation">
<button type="button" class="nav-link active" data-bs-toggle="tab" data-bs-target="#home" role="tab" aria-selected="true">Home</button>
</li>
<li class="nav-item" role="presentation">
<button type="button" class="nav-link" data-bs-toggle="tab" data-bs-target="#profile" role="tab">Profile</button>
</li>
<li class="nav-item" role="presentation">
<button type="button" class="nav-link" data-bs-toggle="tab" data-bs-target="#fat" role="tab">@fat</button>
</li>
<li class="nav-item" role="presentation">
<button type="button" class="nav-link" data-bs-toggle="tab" data-bs-target="#mdo" role="tab">@mdo</button>
</li>
</ul>
<div class="tab-content" role="tablist">
<div class="tab-pane active" id="home" role="tabpanel">
<p>Raw denim you probably haven't heard of them jean shorts Austin. Nesciunt tofu stumptown aliqua, retro synth master cleanse. Mustache cliche tempor, williamsburg carles vegan helvetica. Reprehenderit butcher retro keffiyeh dreamcatcher synth. Cosby sweater eu banh mi, qui irure terry richardson ex squid. Aliquip placeat salvia cillum iphone. Seitan aliquip quis cardigan american apparel, butcher voluptate nisi qui.</p>
<p>Raw denim you probably haven't heard of them jean shorts Austin. Nesciunt tofu stumptown aliqua, retro synth master cleanse. Mustache cliche tempor, williamsburg carles vegan helvetica. Reprehenderit butcher retro keffiyeh dreamcatcher synth. Cosby sweater eu banh mi, qui irure terry richardson ex squid. Aliquip placeat salvia cillum iphone. Seitan aliquip quis cardigan american apparel, butcher voluptate nisi qui.</p>
</div>
<div class="tab-pane" id="profile" role="tabpanel">
<p>Food truck fixie locavore, accusamus mcsweeney's marfa nulla single-origin coffee squid. Exercitation +1 labore velit, blog sartorial PBR leggings next level wes anderson artisan four loko farm-to-table craft beer twee. Qui photo booth letterpress, commodo enim craft beer mlkshk aliquip jean shorts ullamco ad vinyl cillum PBR. Homo nostrud organic, assumenda labore aesthetic magna delectus mollit. Keytar helvetica VHS salvia yr, vero magna velit sapiente labore stumptown. Vegan fanny pack odio cillum wes anderson 8-bit, sustainable jean shorts beard ut DIY ethical culpa terry richardson biodiesel. Art party scenester stumptown, tumblr butcher vero sint qui sapiente accusamus tattooed echo park.</p>
<p>Food truck fixie locavore, accusamus mcsweeney's marfa nulla single-origin coffee squid. Exercitation +1 labore velit, blog sartorial PBR leggings next level wes anderson artisan four loko farm-to-table craft beer twee. Qui photo booth letterpress, commodo enim craft beer mlkshk aliquip jean shorts ullamco ad vinyl cillum PBR. Homo nostrud organic, assumenda labore aesthetic magna delectus mollit. Keytar helvetica VHS salvia yr, vero magna velit sapiente labore stumptown. Vegan fanny pack odio cillum wes anderson 8-bit, sustainable jean shorts beard ut DIY ethical culpa terry richardson biodiesel. Art party scenester stumptown, tumblr butcher vero sint qui sapiente accusamus tattooed echo park.</p>
</div>
<div class="tab-pane" id="fat" role="tabpanel">
<p>Etsy mixtape wayfarers, ethical wes anderson tofu before they sold out mcsweeney's organic lomo retro fanny pack lo-fi farm-to-table readymade. Messenger bag gentrify pitchfork tattooed craft beer, iphone skateboard locavore carles etsy salvia banksy hoodie helvetica. DIY synth PBR banksy irony. Leggings gentrify squid 8-bit cred pitchfork. Williamsburg banh mi whatever gluten-free, carles pitchfork biodiesel fixie etsy retro mlkshk vice blog. Scenester cred you probably haven't heard of them, vinyl craft beer blog stumptown. Pitchfork sustainable tofu synth chambray yr.</p>
<p>Etsy mixtape wayfarers, ethical wes anderson tofu before they sold out mcsweeney's organic lomo retro fanny pack lo-fi farm-to-table readymade. Messenger bag gentrify pitchfork tattooed craft beer, iphone skateboard locavore carles etsy salvia banksy hoodie helvetica. DIY synth PBR banksy irony. Leggings gentrify squid 8-bit cred pitchfork. Williamsburg banh mi whatever gluten-free, carles pitchfork biodiesel fixie etsy retro mlkshk vice blog. Scenester cred you probably haven't heard of them, vinyl craft beer blog stumptown. Pitchfork sustainable tofu synth chambray yr.</p>
</div>
<div class="tab-pane" id="mdo" role="tabpanel">
<p>Trust fund seitan letterpress, keytar raw denim keffiyeh etsy art party before they sold out master cleanse gluten-free squid scenester freegan cosby sweater. Fanny pack portland seitan DIY, art party locavore wolf cliche high life echo park Austin. Cred vinyl keffiyeh DIY salvia PBR, banh mi before they sold out farm-to-table VHS viral locavore cosby sweater. Lomo wolf viral, mustache readymade thundercats keffiyeh craft beer marfa ethical. Wolf salvia freegan, sartorial keffiyeh echo park vegan.</p>
<p>Trust fund seitan letterpress, keytar raw denim keffiyeh etsy art party before they sold out master cleanse gluten-free squid scenester freegan cosby sweater. Fanny pack portland seitan DIY, art party locavore wolf cliche high life echo park Austin. Cred vinyl keffiyeh DIY salvia PBR, banh mi before they sold out farm-to-table VHS viral locavore cosby sweater. Lomo wolf viral, mustache readymade thundercats keffiyeh craft beer marfa ethical. Wolf salvia freegan, sartorial keffiyeh echo park vegan.</p>
</div>
</div>
<h4>Tabs with fade</h4>
<ul class="nav nav-tabs" role="tablist">
<li class="nav-item" role="presentation">
<button type="button" class="nav-link active" data-bs-toggle="tab" data-bs-target="#home2" role="tab" aria-selected="true">Home</button>
</li>
<li class="nav-item" role="presentation">
<button type="button" class="nav-link" data-bs-toggle="tab" data-bs-target="#profile2" role="tab">Profile</button>
</li>
<li class="nav-item" role="presentation">
<button type="button" class="nav-link" data-bs-toggle="tab" data-bs-target="#fat2" role="tab">@fat</button>
</li>
<li class="nav-item" role="presentation">
<button type="button" class="nav-link" data-bs-toggle="tab" data-bs-target="#mdo2" role="tab">@mdo</button>
</li>
</ul>
<div class="tab-content" role="tablist">
<div class="tab-pane fade show active" id="home2" role="tabpanel">
<p>Raw denim you probably haven't heard of them jean shorts Austin. Nesciunt tofu stumptown aliqua, retro synth master cleanse. Mustache cliche tempor, williamsburg carles vegan helvetica. Reprehenderit butcher retro keffiyeh dreamcatcher synth. Cosby sweater eu banh mi, qui irure terry richardson ex squid. Aliquip placeat salvia cillum iphone. Seitan aliquip quis cardigan american apparel, butcher voluptate nisi qui.</p>
<p>Raw denim you probably haven't heard of them jean shorts Austin. Nesciunt tofu stumptown aliqua, retro synth master cleanse. Mustache cliche tempor, williamsburg carles vegan helvetica. Reprehenderit butcher retro keffiyeh dreamcatcher synth. Cosby sweater eu banh mi, qui irure terry richardson ex squid. Aliquip placeat salvia cillum iphone. Seitan aliquip quis cardigan american apparel, butcher voluptate nisi qui.</p>
</div>
<div class="tab-pane fade" id="profile2" role="tabpanel">
<p>Food truck fixie locavore, accusamus mcsweeney's marfa nulla single-origin coffee squid. Exercitation +1 labore velit, blog sartorial PBR leggings next level wes anderson artisan four loko farm-to-table craft beer twee. Qui photo booth letterpress, commodo enim craft beer mlkshk aliquip jean shorts ullamco ad vinyl cillum PBR. Homo nostrud organic, assumenda labore aesthetic magna delectus mollit. Keytar helvetica VHS salvia yr, vero magna velit sapiente labore stumptown. Vegan fanny pack odio cillum wes anderson 8-bit, sustainable jean shorts beard ut DIY ethical culpa terry richardson biodiesel. Art party scenester stumptown, tumblr butcher vero sint qui sapiente accusamus tattooed echo park.</p>
<p>Food truck fixie locavore, accusamus mcsweeney's marfa nulla single-origin coffee squid. Exercitation +1 labore velit, blog sartorial PBR leggings next level wes anderson artisan four loko farm-to-table craft beer twee. Qui photo booth letterpress, commodo enim craft beer mlkshk aliquip jean shorts ullamco ad vinyl cillum PBR. Homo nostrud organic, assumenda labore aesthetic magna delectus mollit. Keytar helvetica VHS salvia yr, vero magna velit sapiente labore stumptown. Vegan fanny pack odio cillum wes anderson 8-bit, sustainable jean shorts beard ut DIY ethical culpa terry richardson biodiesel. Art party scenester stumptown, tumblr butcher vero sint qui sapiente accusamus tattooed echo park.</p>
</div>
<div class="tab-pane fade" id="fat2" role="tabpanel">
<p>Etsy mixtape wayfarers, ethical wes anderson tofu before they sold out mcsweeney's organic lomo retro fanny pack lo-fi farm-to-table readymade. Messenger bag gentrify pitchfork tattooed craft beer, iphone skateboard locavore carles etsy salvia banksy hoodie helvetica. DIY synth PBR banksy irony. Leggings gentrify squid 8-bit cred pitchfork. Williamsburg banh mi whatever gluten-free, carles pitchfork biodiesel fixie etsy retro mlkshk vice blog. Scenester cred you probably haven't heard of them, vinyl craft beer blog stumptown. Pitchfork sustainable tofu synth chambray yr.</p>
<p>Etsy mixtape wayfarers, ethical wes anderson tofu before they sold out mcsweeney's organic lomo retro fanny pack lo-fi farm-to-table readymade. Messenger bag gentrify pitchfork tattooed craft beer, iphone skateboard locavore carles etsy salvia banksy hoodie helvetica. DIY synth PBR banksy irony. Leggings gentrify squid 8-bit cred pitchfork. Williamsburg banh mi whatever gluten-free, carles pitchfork biodiesel fixie etsy retro mlkshk vice blog. Scenester cred you probably haven't heard of them, vinyl craft beer blog stumptown. Pitchfork sustainable tofu synth chambray yr.</p>
</div>
<div class="tab-pane fade" id="mdo2" role="tabpanel">
<p>Trust fund seitan letterpress, keytar raw denim keffiyeh etsy art party before they sold out master cleanse gluten-free squid scenester freegan cosby sweater. Fanny pack portland seitan DIY, art party locavore wolf cliche high life echo park Austin. Cred vinyl keffiyeh DIY salvia PBR, banh mi before they sold out farm-to-table VHS viral locavore cosby sweater. Lomo wolf viral, mustache readymade thundercats keffiyeh craft beer marfa ethical. Wolf salvia freegan, sartorial keffiyeh echo park vegan.</p>
<p>Trust fund seitan letterpress, keytar raw denim keffiyeh etsy art party before they sold out master cleanse gluten-free squid scenester freegan cosby sweater. Fanny pack portland seitan DIY, art party locavore wolf cliche high life echo park Austin. Cred vinyl keffiyeh DIY salvia PBR, banh mi before they sold out farm-to-table VHS viral locavore cosby sweater. Lomo wolf viral, mustache readymade thundercats keffiyeh craft beer marfa ethical. Wolf salvia freegan, sartorial keffiyeh echo park vegan.</p>
</div>
</div>
<h4>Tabs without fade (no initially active pane)</h4>
<ul class="nav nav-tabs" role="tablist">
<li class="nav-item" role="presentation">
<button type="button" class="nav-link" data-bs-toggle="tab" data-bs-target="#home3" role="tab">Home</button>
</li>
<li class="nav-item" role="presentation">
<button type="button" class="nav-link" data-bs-toggle="tab" data-bs-target="#profile3" role="tab">Profile</button>
</li>
<li class="nav-item" role="presentation">
<button type="button" class="nav-link" data-bs-toggle="tab" data-bs-target="#fat3" role="tab">@fat</button>
</li>
<li class="nav-item" role="presentation">
<button type="button" class="nav-link" data-bs-toggle="tab" data-bs-target="#mdo3" role="tab">@mdo</button>
</li>
</ul>
<div class="tab-content" role="tablist">
<div class="tab-pane" id="home3" role="tabpanel">
<p>Raw denim you probably haven't heard of them jean shorts Austin. Nesciunt tofu stumptown aliqua, retro synth master cleanse. Mustache cliche tempor, williamsburg carles vegan helvetica. Reprehenderit butcher retro keffiyeh dreamcatcher synth. Cosby sweater eu banh mi, qui irure terry richardson ex squid. Aliquip placeat salvia cillum iphone. Seitan aliquip quis cardigan american apparel, butcher voluptate nisi qui.</p>
<p>Raw denim you probably haven't heard of them jean shorts Austin. Nesciunt tofu stumptown aliqua, retro synth master cleanse. Mustache cliche tempor, williamsburg carles vegan helvetica. Reprehenderit butcher retro keffiyeh dreamcatcher synth. Cosby sweater eu banh mi, qui irure terry richardson ex squid. Aliquip placeat salvia cillum iphone. Seitan aliquip quis cardigan american apparel, butcher voluptate nisi qui.</p>
</div>
<div class="tab-pane" id="profile3" role="tabpanel">
<p>Food truck fixie locavore, accusamus mcsweeney's marfa nulla single-origin coffee squid. Exercitation +1 labore velit, blog sartorial PBR leggings next level wes anderson artisan four loko farm-to-table craft beer twee. Qui photo booth letterpress, commodo enim craft beer mlkshk aliquip jean shorts ullamco ad vinyl cillum PBR. Homo nostrud organic, assumenda labore aesthetic magna delectus mollit. Keytar helvetica VHS salvia yr, vero magna velit sapiente labore stumptown. Vegan fanny pack odio cillum wes anderson 8-bit, sustainable jean shorts beard ut DIY ethical culpa terry richardson biodiesel. Art party scenester stumptown, tumblr butcher vero sint qui sapiente accusamus tattooed echo park.</p>
<p>Food truck fixie locavore, accusamus mcsweeney's marfa nulla single-origin coffee squid. Exercitation +1 labore velit, blog sartorial PBR leggings next level wes anderson artisan four loko farm-to-table craft beer twee. Qui photo booth letterpress, commodo enim craft beer mlkshk aliquip jean shorts ullamco ad vinyl cillum PBR. Homo nostrud organic, assumenda labore aesthetic magna delectus mollit. Keytar helvetica VHS salvia yr, vero magna velit sapiente labore stumptown. Vegan fanny pack odio cillum wes anderson 8-bit, sustainable jean shorts beard ut DIY ethical culpa terry richardson biodiesel. Art party scenester stumptown, tumblr butcher vero sint qui sapiente accusamus tattooed echo park.</p>
</div>
<div class="tab-pane" id="fat3" role="tabpanel">
<p>Etsy mixtape wayfarers, ethical wes anderson tofu before they sold out mcsweeney's organic lomo retro fanny pack lo-fi farm-to-table readymade. Messenger bag gentrify pitchfork tattooed craft beer, iphone skateboard locavore carles etsy salvia banksy hoodie helvetica. DIY synth PBR banksy irony. Leggings gentrify squid 8-bit cred pitchfork. Williamsburg banh mi whatever gluten-free, carles pitchfork biodiesel fixie etsy retro mlkshk vice blog. Scenester cred you probably haven't heard of them, vinyl craft beer blog stumptown. Pitchfork sustainable tofu synth chambray yr.</p>
<p>Etsy mixtape wayfarers, ethical wes anderson tofu before they sold out mcsweeney's organic lomo retro fanny pack lo-fi farm-to-table readymade. Messenger bag gentrify pitchfork tattooed craft beer, iphone skateboard locavore carles etsy salvia banksy hoodie helvetica. DIY synth PBR banksy irony. Leggings gentrify squid 8-bit cred pitchfork. Williamsburg banh mi whatever gluten-free, carles pitchfork biodiesel fixie etsy retro mlkshk vice blog. Scenester cred you probably haven't heard of them, vinyl craft beer blog stumptown. Pitchfork sustainable tofu synth chambray yr.</p>
</div>
<div class="tab-pane" id="mdo3" role="tabpanel">
<p>Trust fund seitan letterpress, keytar raw denim keffiyeh etsy art party before they sold out master cleanse gluten-free squid scenester freegan cosby sweater. Fanny pack portland seitan DIY, art party locavore wolf cliche high life echo park Austin. Cred vinyl keffiyeh DIY salvia PBR, banh mi before they sold out farm-to-table VHS viral locavore cosby sweater. Lomo wolf viral, mustache readymade thundercats keffiyeh craft beer marfa ethical. Wolf salvia freegan, sartorial keffiyeh echo park vegan.</p>
<p>Trust fund seitan letterpress, keytar raw denim keffiyeh etsy art party before they sold out master cleanse gluten-free squid scenester freegan cosby sweater. Fanny pack portland seitan DIY, art party locavore wolf cliche high life echo park Austin. Cred vinyl keffiyeh DIY salvia PBR, banh mi before they sold out farm-to-table VHS viral locavore cosby sweater. Lomo wolf viral, mustache readymade thundercats keffiyeh craft beer marfa ethical. Wolf salvia freegan, sartorial keffiyeh echo park vegan.</p>
</div>
</div>
<h4>Tabs with fade (no initially active pane)</h4>
<ul class="nav nav-tabs" role="tablist">
<li class="nav-item" role="presentation">
<button type="button" class="nav-link" data-bs-toggle="tab" data-bs-target="#home4" role="tab">Home</button>
</li>
<li class="nav-item" role="presentation">
<button type="button" class="nav-link" data-bs-toggle="tab" data-bs-target="#profile4" role="tab">Profile</button>
</li>
<li class="nav-item" role="presentation">
<button type="button" class="nav-link" data-bs-toggle="tab" data-bs-target="#fat4" role="tab">@fat</button>
</li>
<li class="nav-item" role="presentation">
<button type="button" class="nav-link" data-bs-toggle="tab" data-bs-target="#mdo4" role="tab">@mdo</button>
</li>
</ul>
<div class="tab-content">
<div class="tab-pane fade" id="home4" role="tabpanel">
<p>Raw denim you probably haven't heard of them jean shorts Austin. Nesciunt tofu stumptown aliqua, retro synth master cleanse. Mustache cliche tempor, williamsburg carles vegan helvetica. Reprehenderit butcher retro keffiyeh dreamcatcher synth. Cosby sweater eu banh mi, qui irure terry richardson ex squid. Aliquip placeat salvia cillum iphone. Seitan aliquip quis cardigan american apparel, butcher voluptate nisi qui.</p>
<p>Raw denim you probably haven't heard of them jean shorts Austin. Nesciunt tofu stumptown aliqua, retro synth master cleanse. Mustache cliche tempor, williamsburg carles vegan helvetica. Reprehenderit butcher retro keffiyeh dreamcatcher synth. Cosby sweater eu banh mi, qui irure terry richardson ex squid. Aliquip placeat salvia cillum iphone. Seitan aliquip quis cardigan american apparel, butcher voluptate nisi qui.</p>
</div>
<div class="tab-pane fade" id="profile4" role="tabpanel">
<p>Food truck fixie locavore, accusamus mcsweeney's marfa nulla single-origin coffee squid. Exercitation +1 labore velit, blog sartorial PBR leggings next level wes anderson artisan four loko farm-to-table craft beer twee. Qui photo booth letterpress, commodo enim craft beer mlkshk aliquip jean shorts ullamco ad vinyl cillum PBR. Homo nostrud organic, assumenda labore aesthetic magna delectus mollit. Keytar helvetica VHS salvia yr, vero magna velit sapiente labore stumptown. Vegan fanny pack odio cillum wes anderson 8-bit, sustainable jean shorts beard ut DIY ethical culpa terry richardson biodiesel. Art party scenester stumptown, tumblr butcher vero sint qui sapiente accusamus tattooed echo park.</p>
<p>Food truck fixie locavore, accusamus mcsweeney's marfa nulla single-origin coffee squid. Exercitation +1 labore velit, blog sartorial PBR leggings next level wes anderson artisan four loko farm-to-table craft beer twee. Qui photo booth letterpress, commodo enim craft beer mlkshk aliquip jean shorts ullamco ad vinyl cillum PBR. Homo nostrud organic, assumenda labore aesthetic magna delectus mollit. Keytar helvetica VHS salvia yr, vero magna velit sapiente labore stumptown. Vegan fanny pack odio cillum wes anderson 8-bit, sustainable jean shorts beard ut DIY ethical culpa terry richardson biodiesel. Art party scenester stumptown, tumblr butcher vero sint qui sapiente accusamus tattooed echo park.</p>
</div>
<div class="tab-pane fade" id="fat4" role="tabpanel">
<p>Etsy mixtape wayfarers, ethical wes anderson tofu before they sold out mcsweeney's organic lomo retro fanny pack lo-fi farm-to-table readymade. Messenger bag gentrify pitchfork tattooed craft beer, iphone skateboard locavore carles etsy salvia banksy hoodie helvetica. DIY synth PBR banksy irony. Leggings gentrify squid 8-bit cred pitchfork. Williamsburg banh mi whatever gluten-free, carles pitchfork biodiesel fixie etsy retro mlkshk vice blog. Scenester cred you probably haven't heard of them, vinyl craft beer blog stumptown. Pitchfork sustainable tofu synth chambray yr.</p>
<p>Etsy mixtape wayfarers, ethical wes anderson tofu before they sold out mcsweeney's organic lomo retro fanny pack lo-fi farm-to-table readymade. Messenger bag gentrify pitchfork tattooed craft beer, iphone skateboard locavore carles etsy salvia banksy hoodie helvetica. DIY synth PBR banksy irony. Leggings gentrify squid 8-bit cred pitchfork. Williamsburg banh mi whatever gluten-free, carles pitchfork biodiesel fixie etsy retro mlkshk vice blog. Scenester cred you probably haven't heard of them, vinyl craft beer blog stumptown. Pitchfork sustainable tofu synth chambray yr.</p>
</div>
<div class="tab-pane fade" id="mdo4" role="tabpanel">
<p>Trust fund seitan letterpress, keytar raw denim keffiyeh etsy art party before they sold out master cleanse gluten-free squid scenester freegan cosby sweater. Fanny pack portland seitan DIY, art party locavore wolf cliche high life echo park Austin. Cred vinyl keffiyeh DIY salvia PBR, banh mi before they sold out farm-to-table VHS viral locavore cosby sweater. Lomo wolf viral, mustache readymade thundercats keffiyeh craft beer marfa ethical. Wolf salvia freegan, sartorial keffiyeh echo park vegan.</p>
<p>Trust fund seitan letterpress, keytar raw denim keffiyeh etsy art party before they sold out master cleanse gluten-free squid scenester freegan cosby sweater. Fanny pack portland seitan DIY, art party locavore wolf cliche high life echo park Austin. Cred vinyl keffiyeh DIY salvia PBR, banh mi before they sold out farm-to-table VHS viral locavore cosby sweater. Lomo wolf viral, mustache readymade thundercats keffiyeh craft beer marfa ethical. Wolf salvia freegan, sartorial keffiyeh echo park vegan.</p>
</div>
</div>
<h4>Tabs with nav and using links (with fade)</h4>
<nav>
<div class="nav nav-pills" id="nav-tab" role="tablist">
<a class="nav-link nav-item active" role="tab" data-bs-toggle="tab" href="#home5">Home</a>
<a class="nav-link nav-item" role="tab" data-bs-toggle="tab" href="#profile5">Profile</a>
<a class="nav-link nav-item" role="tab" data-bs-toggle="tab" href="#fat5">@fat</a>
<a class="nav-link nav-item" role="tab" data-bs-toggle="tab" href="#mdo5">@mdo</a>
<a class="nav-link nav-item disabled" role="tab" href="#" aria-disabled="true">Disabled</a>
</div>
</nav>
<div class="tab-content">
<div class="tab-pane fade show active" id="home5" role="tabpanel">
<p>Raw denim you probably haven't heard of them jean shorts Austin. Nesciunt tofu stumptown aliqua, retro synth master cleanse. Mustache cliche tempor, williamsburg carles vegan helvetica. Reprehenderit butcher retro keffiyeh dreamcatcher synth. Cosby sweater eu banh mi, qui irure terry richardson ex squid. Aliquip placeat salvia cillum iphone. Seitan aliquip quis cardigan american apparel, butcher voluptate nisi qui.</p>
<p>Raw denim you probably haven't heard of them jean shorts Austin. Nesciunt tofu stumptown aliqua, retro synth master cleanse. Mustache cliche tempor, williamsburg carles vegan helvetica. Reprehenderit butcher retro keffiyeh dreamcatcher synth. Cosby sweater eu banh mi, qui irure terry richardson ex squid. Aliquip placeat salvia cillum iphone. Seitan aliquip quis cardigan american apparel, butcher voluptate nisi qui.</p>
</div>
<div class="tab-pane fade" id="profile5" role="tabpanel">
<p>Raw denim you probably haven't heard of them jean shorts Austin. Nesciunt tofu stumptown aliqua, retro synth master cleanse. Mustache cliche tempor, williamsburg carles vegan helvetica. Reprehenderit butcher retro keffiyeh dreamcatcher synth. Cosby sweater eu banh mi, qui irure terry richardson ex squid. Aliquip placeat salvia cillum iphone. Seitan aliquip quis cardigan american apparel, butcher voluptate nisi qui.</p>
<p>Raw denim you probably haven't heard of them jean shorts Austin. Nesciunt tofu stumptown aliqua, retro synth master cleanse. Mustache cliche tempor, williamsburg carles vegan helvetica. Reprehenderit butcher retro keffiyeh dreamcatcher synth. Cosby sweater eu banh mi, qui irure terry richardson ex squid. Aliquip placeat salvia cillum iphone. Seitan aliquip quis cardigan american apparel, butcher voluptate nisi qui.</p>
</div>
<div class="tab-pane fade" id="fat5" role="tabpanel">
<p>Etsy mixtape wayfarers, ethical wes anderson tofu before they sold out mcsweeney's organic lomo retro fanny pack lo-fi farm-to-table readymade. Messenger bag gentrify pitchfork tattooed craft beer, iphone skateboard locavore carles etsy salvia banksy hoodie helvetica. DIY synth PBR banksy irony. Leggings gentrify squid 8-bit cred pitchfork. Williamsburg banh mi whatever gluten-free, carles pitchfork biodiesel fixie etsy retro mlkshk vice blog. Scenester cred you probably haven't heard of them, vinyl craft beer blog stumptown. Pitchfork sustainable tofu synth chambray yr.</p>
<p>Etsy mixtape wayfarers, ethical wes anderson tofu before they sold out mcsweeney's organic lomo retro fanny pack lo-fi farm-to-table readymade. Messenger bag gentrify pitchfork tattooed craft beer, iphone skateboard locavore carles etsy salvia banksy hoodie helvetica. DIY synth PBR banksy irony. Leggings gentrify squid 8-bit cred pitchfork. Williamsburg banh mi whatever gluten-free, carles pitchfork biodiesel fixie etsy retro mlkshk vice blog. Scenester cred you probably haven't heard of them, vinyl craft beer blog stumptown. Pitchfork sustainable tofu synth chambray yr.</p>
</div>
<div class="tab-pane fade" id="mdo5" role="tabpanel">
<p>Trust fund seitan letterpress, keytar raw denim keffiyeh etsy art party before they sold out master cleanse gluten-free squid scenester freegan cosby sweater. Fanny pack portland seitan DIY, art party locavore wolf cliche high life echo park Austin. Cred vinyl keffiyeh DIY salvia PBR, banh mi before they sold out farm-to-table VHS viral locavore cosby sweater. Lomo wolf viral, mustache readymade thundercats keffiyeh craft beer marfa ethical. Wolf salvia freegan, sartorial keffiyeh echo park vegan.</p>
<p>Trust fund seitan letterpress, keytar raw denim keffiyeh etsy art party before they sold out master cleanse gluten-free squid scenester freegan cosby sweater. Fanny pack portland seitan DIY, art party locavore wolf cliche high life echo park Austin. Cred vinyl keffiyeh DIY salvia PBR, banh mi before they sold out farm-to-table VHS viral locavore cosby sweater. Lomo wolf viral, mustache readymade thundercats keffiyeh craft beer marfa ethical. Wolf salvia freegan, sartorial keffiyeh echo park vegan.</p>
</div>
</div>
<h4>Tabs with list-group (with fade)</h4>
<div class="row">
<div class="col-4">
<div class="list-group" id="list-tab" role="tablist">
<button type="button" class="list-group-item list-group-item-action active" id="list-home-list" data-bs-toggle="tab" data-bs-target="#list-home" role="tab" aria-controls="list-home" aria-selected="true">Home</button>
<button type="button" class="list-group-item list-group-item-action" id="list-profile-list" data-bs-toggle="tab" data-bs-target="#list-profile" role="tab" aria-controls="list-profile">Profile</button>
<button type="button" class="list-group-item list-group-item-action" id="list-messages-list" data-bs-toggle="tab" data-bs-target="#list-messages" role="tab" aria-controls="list-messages">Messages</button>
<button type="button" class="list-group-item list-group-item-action" id="list-settings-list" data-bs-toggle="tab" data-bs-target="#list-settings" role="tab" aria-controls="list-settings">Settings</button>
</div>
</div>
<div class="col-8">
<div class="tab-content" id="nav-tabContent">
<div class="tab-pane fade show active" id="list-home" role="tabpanel" aria-labelledby="list-home-list">
<p>Velit aute mollit ipsum ad dolor consectetur nulla officia culpa adipisicing exercitation fugiat tempor. Voluptate deserunt sit sunt nisi aliqua fugiat proident ea ut. Mollit voluptate reprehenderit occaecat nisi ad non minim tempor sunt voluptate consectetur exercitation id ut nulla. Ea et fugiat aliquip nostrud sunt incididunt consectetur culpa aliquip eiusmod dolor. Anim ad Lorem aliqua in cupidatat nisi enim eu nostrud do aliquip veniam minim.</p>
</div>
<div class="tab-pane fade" id="list-profile" role="tabpanel" aria-labelledby="list-profile-list">
<p>Cupidatat quis ad sint excepteur laborum in esse qui. Et excepteur consectetur ex nisi eu do cillum ad laborum. Mollit et eu officia dolore sunt Lorem culpa qui commodo velit ex amet id ex. Officia anim incididunt laboris deserunt anim aute dolor incididunt veniam aute dolore do exercitation. Dolor nisi culpa ex ad irure in elit eu dolore. Ad laboris ipsum reprehenderit irure non commodo enim culpa commodo veniam incididunt veniam ad.</p>
</div>
<div class="tab-pane fade" id="list-messages" role="tabpanel" aria-labelledby="list-messages-list">
<p>Ut ut do pariatur aliquip aliqua aliquip exercitation do nostrud commodo reprehenderit aute ipsum voluptate. Irure Lorem et laboris nostrud amet cupidatat cupidatat anim do ut velit mollit consequat enim tempor. Consectetur est minim nostrud nostrud consectetur irure labore voluptate irure. Ipsum id Lorem sit sint voluptate est pariatur eu ad cupidatat et deserunt culpa sit eiusmod deserunt. Consectetur et fugiat anim do eiusmod aliquip nulla laborum elit adipisicing pariatur cillum.</p>
</div>
<div class="tab-pane fade" id="list-settings" role="tabpanel" aria-labelledby="list-settings-list">
<p>Irure enim occaecat labore sit qui aliquip reprehenderit amet velit. Deserunt ullamco ex elit nostrud ut dolore nisi officia magna sit occaecat laboris sunt dolor. Nisi eu minim cillum occaecat aute est cupidatat aliqua labore aute occaecat ea aliquip sunt amet. Aute mollit dolor ut exercitation irure commodo non amet consectetur quis amet culpa. Quis ullamco nisi amet qui aute irure eu. Magna labore dolor quis ex labore id nostrud deserunt dolor eiusmod eu pariatur culpa mollit in irure.</p>
</div>
</div>
</div>
</div>
</div>
<script src="../../../dist/js/tabler.min.js"></script>
<script>window.bootstrap = window.tabler && window.tabler.bootstrap || {};</script>
</body>
</html>

View File

@@ -0,0 +1,71 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="../../../dist/css/tabler.min.css" rel="stylesheet">
<title>Toast</title>
<style>
.notifications {
position: absolute;
top: 10px;
right: 10px;
}
</style>
</head>
<body>
<div class="container">
<h1>Toast <small>Bootstrap Visual Test</small></h1>
<div class="row mt-3">
<div class="col-md-12">
<button id="btnShowToast" class="btn btn-primary">Show toast</button>
<button id="btnHideToast" class="btn btn-primary">Hide toast</button>
</div>
</div>
</div>
<div class="notifications">
<div id="toastAutoHide" class="toast" role="alert" aria-live="assertive" aria-atomic="true" data-bs-delay="2000">
<div class="toast-header">
<span class="d-block bg-primary rounded me-2" style="width: 20px; height: 20px;"></span>
<strong class="me-auto">Bootstrap</strong>
<small>11 mins ago</small>
</div>
<div class="toast-body">
Hello, world! This is a toast message with <strong>autohide</strong> in 2 seconds
</div>
</div>
<div class="toast" data-bs-autohide="false" role="alert" aria-live="assertive" aria-atomic="true">
<div class="toast-header">
<span class="d-block bg-primary rounded me-2" style="width: 20px; height: 20px;"></span>
<strong class="me-auto">Bootstrap</strong>
<small class="text-body-secondary">2 seconds ago</small>
<button type="button" class="ms-2 mb-1 btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
<div class="toast-body">
Heads up, toasts will stack automatically
</div>
</div>
</div>
<script src="../../../dist/js/tabler.min.js"></script>
<script>window.bootstrap = window.tabler && window.tabler.bootstrap || {};</script>
<script>
/* global bootstrap: false */
window.addEventListener('load', () => {
document.querySelectorAll('.toast').forEach(toastEl => new bootstrap.Toast(toastEl))
document.getElementById('btnShowToast').addEventListener('click', () => {
document.querySelectorAll('.toast').forEach(toastEl => bootstrap.Toast.getInstance(toastEl).show())
})
document.getElementById('btnHideToast').addEventListener('click', () => {
document.querySelectorAll('.toast').forEach(toastEl => bootstrap.Toast.getInstance(toastEl).hide())
})
})
</script>
</body>
</html>

View File

@@ -0,0 +1,139 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="../../../dist/css/tabler.min.css" rel="stylesheet">
<title>Tooltip</title>
<style>
#target {
border: 1px solid;
width: 100px;
height: 50px;
margin-left: 50px;
transform: rotate(270deg);
margin-top: 100px;
}
</style>
</head>
<body>
<div class="container">
<h1>Tooltip <small>Bootstrap Visual Test</small></h1>
<p class="text-body-secondary">Tight pants next level keffiyeh <a href="#" data-bs-toggle="tooltip" title="Default tooltip">you probably</a> haven't heard of them. Photo booth beard raw denim letterpress vegan messenger bag stumptown. Farm-to-table seitan, mcsweeney's fixie sustainable quinoa 8-bit american apparel <a href="#" data-bs-toggle="tooltip" title="Another tooltip">have a</a> terry richardson vinyl chambray. Beard stumptown, cardigans banh mi lomo thundercats. Tofu biodiesel williamsburg marfa, four loko mcsweeney's cleanse vegan chambray. A really ironic artisan <a href="#" data-bs-toggle="tooltip" title="Another one here too">whatever keytar</a>, scenester farm-to-table banksy Austin <a href="#" data-bs-toggle="tooltip" title="The last tip!">freegan cred</a> raw denim single-origin coffee viral.</p>
<hr>
<div class="row">
<p>
<button type="button" class="btn btn-secondary" data-bs-toggle="tooltip" data-bs-placement="auto" title="Tooltip on auto">
Tooltip on auto
</button>
<button type="button" class="btn btn-secondary" data-bs-toggle="tooltip" data-bs-placement="top" title="Tooltip on top">
Tooltip on top
</button>
<button type="button" class="btn btn-secondary" data-bs-toggle="tooltip" data-bs-placement="right" title="Tooltip on right">
Tooltip on end
</button>
<button type="button" class="btn btn-secondary" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Tooltip on bottom">
Tooltip on bottom
</button>
<button type="button" class="btn btn-secondary" data-bs-toggle="tooltip" data-bs-placement="left" title="Tooltip on left">
Tooltip on start
</button>
</p>
</div>
<div class="row">
<p>
<button type="button" class="btn btn-secondary" data-bs-toggle="tooltip" data-bs-placement="left" title="Tooltip with container (selector)" data-bs-container="#customContainer">
Tooltip with container (selector)
</button>
<button id="tooltipElement" type="button" class="btn btn-secondary" data-bs-placement="left" title="Tooltip with container (element)">
Tooltip with container (element)
</button>
<button type="button" class="btn btn-secondary" data-bs-toggle="tooltip" data-bs-html="true" title="<em>Tooltip</em> <u>with</u> <b>HTML</b>">
Tooltip with HTML
</button>
<button type="button" class="btn btn-secondary" data-bs-toggle="tooltip" data-bs-placement="left" title="Tooltip with XSS" data-bs-container="<img src=1 onerror=alert(123)>">
Tooltip with XSS
</button>
</p>
</div>
<div class="row">
<div class="col-sm-3">
<div id="target" title="Test tooltip on transformed element"></div>
</div>
<div id="shadow" class="pt-5"></div>
</div>
<div id="customContainer"></div>
<div class="row mt-4 border-top">
<hr>
<div class="h4">Test Selector triggered tooltips</div>
<div id="wrapperTriggeredBySelector">
<div class="py-2 selectorButtonsBlock">
<button type="button" class="btn btn-secondary bs-dynamic-tooltip" title="random title">Using title</button>
<button type="button" class="btn btn-secondary bs-dynamic-tooltip" data-bs-title="random title">Using bs-title</button>
</div>
</div>
<div class="mt-3">
<button type="button" class="btn btn-primary" onclick="duplicateButtons()">Duplicate above two buttons</button>
</div>
</div>
</div>
<script src="../../../dist/js/tabler.min.js"></script>
<script>window.bootstrap = window.tabler && window.tabler.bootstrap || {};</script>
<script>
/* global bootstrap: false */
if (typeof document.body.attachShadow === 'function') {
const shadowRoot = document.getElementById('shadow').attachShadow({ mode: 'open' })
shadowRoot.innerHTML =
'<button id="firstShadowTooltip" type="button" class="btn btn-secondary" data-bs-toggle="tooltip" data-bs-placement="top" title="Tooltip on top in a shadow dom">' +
' Tooltip on top in a shadow dom' +
'</button>' +
'<button id="secondShadowTooltip" type="button" class="btn btn-secondary" data-bs-toggle="tooltip" data-bs-placement="top" title="Tooltip on top in a shadow dom with container option">' +
' Tooltip on top in a shadow dom' +
'</button>'
new bootstrap.Tooltip(shadowRoot.firstChild)
new bootstrap.Tooltip(shadowRoot.getElementById('secondShadowTooltip'), {
container: shadowRoot
})
}
new bootstrap.Tooltip('#tooltipElement', {
container: '#customContainer'
})
const targetTooltip = new bootstrap.Tooltip('#target', {
placement: 'top',
trigger: 'manual'
})
targetTooltip.show()
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(tooltipEl => new bootstrap.Tooltip(tooltipEl))
</script>
<script>
/* global bootstrap: false */
new bootstrap.Tooltip('#wrapperTriggeredBySelector', {
animation: false,
selector: '.bs-dynamic-tooltip'
})
/* eslint-disable no-unused-vars */
function duplicateButtons() {
const buttonsBlock = document.querySelector('.selectorButtonsBlock')// get first
const buttonsBlockClone = buttonsBlock.cloneNode(true)
buttonsBlockClone.innerHTML += new Date().toLocaleString()
document.querySelector('#wrapperTriggeredBySelector').append(buttonsBlockClone)
}
/* eslint-enable no-unused-vars */
</script>
</body>
</html>

View File

@@ -17,7 +17,7 @@
"css-minify-main": "cleancss -O1 --format breakWith=lf --with-rebase --source-map --source-map-inline-sources --output dist/css/ --batch --batch-suffix \".min\" \"dist/css/*.css\" \"!dist/css/*.min.css\" \"!dist/css/*rtl*.css\"",
"css-minify-rtl": "cleancss -O1 --format breakWith=lf --with-rebase --source-map --source-map-inline-sources --output dist/css/ --batch --batch-suffix \".min\" \"dist/css/*rtl.css\" \"!dist/css/*.min.css\"",
"css-lint": "pnpm run css-lint-variables",
"css-lint-variables": "find-unused-sass-variables scss/ node_modules/bootstrap/scss/",
"css-lint-variables": "find-unused-sass-variables scss/",
"js": "pnpm run js-build && pnpm run js-build-min",
"js-build": "concurrently \"pnpm run js-build-standalone\" \"pnpm run js-build-theme\"",
"js-build-theme": "cross-env BASE_NAME=tabler-theme vite build --config .build/vite.config.mts",
@@ -37,7 +37,10 @@
"generate-sri": "tsx .build/generate-sri.ts",
"format:check": "prettier --check \"scss/**/*.scss\" \"js/**/*.{js,ts}\" --cache",
"format:write": "prettier --write \"scss/**/*.scss\" \"js/**/*.{js,ts}\" --cache",
"type-check": "tsc --noEmit"
"type-check": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
},
"repository": {
"type": "git",
@@ -82,103 +85,106 @@
"files": [
{
"path": "./dist/css/tabler.css",
"maxSize": "77 kB"
"maxSize": "80 kB"
},
{
"path": "./dist/css/tabler.min.css",
"maxSize": "72 kB"
"maxSize": "75 kB"
},
{
"path": "./dist/css/tabler.rtl.css",
"maxSize": "77 kB"
"maxSize": "79 kB"
},
{
"path": "./dist/css/tabler.rtl.min.css",
"maxSize": "73 kB"
"maxSize": "75 kB"
},
{
"path": "./dist/css/tabler-flags.css",
"maxSize": "2.5 kB"
"maxSize": "2.6 kB"
},
{
"path": "./dist/css/tabler-flags.min.css",
"maxSize": "2 kB"
"maxSize": "2.1 kB"
},
{
"path": "./dist/css/tabler-payments.css",
"maxSize": "2.3 kB"
"maxSize": "2.4 kB"
},
{
"path": "./dist/css/tabler-payments.min.css",
"maxSize": "2 kB"
"maxSize": "2.1 kB"
},
{
"path": "./dist/css/tabler-socials.css",
"maxSize": "2 kB"
"maxSize": "2.1 kB"
},
{
"path": "./dist/css/tabler-socials.min.css",
"maxSize": "2 kB"
"maxSize": "2.1 kB"
},
{
"path": "./dist/css/tabler-vendors.css",
"maxSize": "7.5 kB"
"maxSize": "7.7 kB"
},
{
"path": "./dist/css/tabler-vendors.min.css",
"maxSize": "6.5 kB"
"maxSize": "6.7 kB"
},
{
"path": "./dist/js/tabler.js",
"maxSize": "60 kB"
"maxSize": "64 kB"
},
{
"path": "./dist/js/tabler.min.js",
"maxSize": "45 kB"
"maxSize": "48 kB"
},
{
"path": "./dist/js/tabler.esm.js",
"maxSize": "60 kB"
"maxSize": "64 kB"
},
{
"path": "./dist/js/tabler.esm.min.js",
"maxSize": "45 kB"
"maxSize": "48 kB"
}
]
},
"dependencies": {
"@popperjs/core": "^2.11.8",
"bootstrap": "5.3.8"
"@popperjs/core": "^2.11.8"
},
"devDependencies": {
"@hotwired/turbo": "^8.0.18",
"@hotwired/turbo": "^8.0.23",
"@melloware/coloris": "^0.25.0",
"@types/node": "^22.0.0",
"apexcharts": "^5.3.6",
"@types/node": "^25.3.5",
"@vitest/browser-playwright": "^4.0.18",
"playwright": "^1.49.0",
"@vitest/coverage-istanbul": "^4.0.18",
"apexcharts": "5.4.0",
"autosize": "^6.0.1",
"choices.js": "^11.1.0",
"clipboard": "^2.0.11",
"countup.js": "^2.9.0",
"countup.js": "^2.10.0",
"driver.js": "^1.4.0",
"dropzone": "^6.0.0-beta.2",
"find-unused-sass-variables": "^6.1.0",
"find-unused-sass-variables": "^6.1.1",
"flatpickr": "^4.6.13",
"fslightbox": "^3.7.4",
"fullcalendar": "^6.1.19",
"geist": "^1.5.1",
"hugerte": "^1.0.9",
"fslightbox": "^3.7.5",
"fullcalendar": "^6.1.20",
"geist": "^1.7.0",
"hugerte": "^1.0.10",
"imask": "^7.6.1",
"jsvectormap": "^1.7.0",
"list.js": "^2.3.1",
"litepicker": "^2.0.12",
"nouislider": "^15.8.1",
"plyr": "^3.8.3",
"signature_pad": "^5.1.1",
"sortablejs": "^1.15.6",
"plyr": "^3.8.4",
"signature_pad": "^5.1.3",
"sortablejs": "^1.15.7",
"star-rating.js": "^4.3.1",
"tom-select": "^2.4.3",
"typed.js": "^2.1.0",
"tom-select": "^2.5.2",
"typed.js": "^3.0.0",
"typescript": "^5.9.3",
"driver.js": "^1.0.0"
"vitest": "^4.0.18"
},
"directories": {
"doc": "docs"

View File

@@ -1,30 +0,0 @@
// Layout & components
@import 'bootstrap/scss/root';
@import 'bootstrap/scss/reboot';
@import 'bootstrap/scss/type';
@import 'bootstrap/scss/images';
@import 'bootstrap/scss/containers';
@import 'bootstrap/scss/grid';
@import 'bootstrap/scss/tables';
@import 'bootstrap/scss/forms';
@import 'bootstrap/scss/buttons';
@import 'bootstrap/scss/transitions';
@import 'bootstrap/scss/dropdown';
@import 'bootstrap/scss/button-group';
@import 'bootstrap/scss/nav';
@import 'bootstrap/scss/navbar';
@import 'bootstrap/scss/card';
@import 'bootstrap/scss/pagination';
@import 'bootstrap/scss/progress';
@import 'bootstrap/scss/list-group';
@import 'bootstrap/scss/toasts';
@import 'bootstrap/scss/modal';
@import 'bootstrap/scss/tooltip';
@import 'bootstrap/scss/popover';
@import 'bootstrap/scss/carousel';
@import 'bootstrap/scss/spinners';
@import 'bootstrap/scss/offcanvas';
@import 'bootstrap/scss/placeholders';
// Utilities
@import 'bootstrap/scss/utilities/api';

View File

@@ -1,6 +0,0 @@
// Config
// @import "bootstrap/scss/variables";
// @import "bootstrap/scss/variables-dark";
// @import "bootstrap/scss/maps";
@import 'bootstrap/scss/mixins';
// @import "bootstrap/scss/utilities";

View File

@@ -1,78 +0,0 @@
@use 'sass:color';
@mixin caret($direction: down) {
$selector: 'after';
@if $direction == 'left' {
$selector: 'before';
}
&:#{$selector} {
content: '';
display: inline-block;
vertical-align: $caret-vertical-align;
width: $caret-width;
height: $caret-width;
border-bottom: 1px var(--#{$prefix}border-style);
border-inline-start: 1px var(--#{$prefix}border-style);
margin-inline-end: 0.1em;
@if $direction != 'left' {
margin-inline-start: $caret-spacing;
} @else {
margin-inline-end: $caret-spacing;
}
@if $direction == down {
transform: rotate(-45deg);
} @else if $direction == up {
transform: rotate(135deg);
} @else if $direction == end {
transform: rotate(-135deg);
} @else {
transform: rotate(45deg);
}
}
@if $direction == 'left' {
&:after {
content: none;
}
}
}
@mixin alert-variant($background: null, $border: null, $color: null) {
// Override bootstrap core
}
@mixin button-variant($background: null, $border: null, $color: null, $hover-background: null, $hover-border: null, $hover-color: null, $active-background: null, $active-border: null, $active-color: null, $disabled-background: null, $disabled-border: null, $disabled-color: null) {
// Override bootstrap core
}
@mixin button-outline-variant($color: null, $color-hover: null, $active-background: null, $active-border: null, $active-color: null) {
// Override bootstrap core
}
//
// TODO: remove when https://github.com/twbs/bootstrap/pull/37425/ will be released
//
@function opaque($background, $foreground) {
@return color.mix(rgba($foreground, 1), $background, color.alpha($foreground) * 100%);
}
@mixin border-radius($radius: $border-radius, $fallback-border-radius: false) {
@if $enable-rounded {
border-radius: valid-radius($radius);
@if $radius != 0 {
@supports (corner-shape: squircle) {
corner-shape: squircle;
border-radius: calc(#{$radius} * 2.5) !important;
}
}
}
@else if $fallback-border-radius !=false {
border-radius: $fallback-border-radius;
}
}

View File

@@ -4,5 +4,3 @@
@import 'maps';
@import 'utilities';
@import 'bootstrap-config';
@import 'bootstrap-override';

View File

@@ -1,5 +1,33 @@
@import 'config';
@import 'bootstrap-components';
// Bootstrap components — managed by Tabler (see core/scss/bootstrap/)
@import 'bootstrap/root';
@import 'bootstrap/reboot';
@import 'bootstrap/type';
@import 'bootstrap/images';
@import 'bootstrap/containers';
@import 'bootstrap/grid';
@import 'bootstrap/tables';
@import 'bootstrap/forms';
@import 'bootstrap/buttons';
@import 'bootstrap/transitions';
@import 'bootstrap/dropdown';
@import 'bootstrap/button-group';
@import 'bootstrap/nav';
@import 'bootstrap/navbar';
@import 'bootstrap/card';
@import 'bootstrap/pagination';
@import 'bootstrap/progress';
@import 'bootstrap/list-group';
@import 'bootstrap/toasts';
@import 'bootstrap/modal';
@import 'bootstrap/tooltip';
@import 'bootstrap/popover';
@import 'bootstrap/carousel';
@import 'bootstrap/spinners';
@import 'bootstrap/offcanvas';
@import 'bootstrap/placeholders';
@import 'bootstrap/utilities/api';
@import 'props';

View File

@@ -150,6 +150,6 @@ $utilities-border-subtle: (
$utilities-links-underline: map-loop($utilities-colors, rgba-css-var, '$key', 'link-underline') !default;
$negative-spacers: if($enable-negative-margins, negativify-map($spacers), null) !default;
$negative-spacers: if(sass($enable-negative-margins): negativify-map($spacers); else: null) !default;
$gutters: $spacers !default;

View File

@@ -1,2 +1,33 @@
@import 'mixins/mixins';
@import 'mixins/functions';
// Bootstrap mixins managed by Tabler
// $enable-rfs is declared before rfs import so rfs's `!default` becomes a no-op.
// Users can override by setting $enable-rfs before importing Tabler.
$enable-rfs: false !default;
@import 'mixins/bootstrap/rfs';
@import 'mixins/bootstrap/deprecate';
@import 'mixins/bootstrap/breakpoints';
@import 'mixins/bootstrap/color-mode';
@import 'mixins/bootstrap/color-scheme';
@import 'mixins/bootstrap/image';
@import 'mixins/bootstrap/resize';
@import 'mixins/bootstrap/visually-hidden';
@import 'mixins/bootstrap/reset-text';
@import 'mixins/bootstrap/text-truncate';
@import 'mixins/bootstrap/utilities';
@import 'mixins/bootstrap/alert';
@import 'mixins/bootstrap/backdrop';
@import 'mixins/bootstrap/buttons';
@import 'mixins/bootstrap/caret';
@import 'mixins/bootstrap/pagination';
@import 'mixins/bootstrap/lists';
@import 'mixins/bootstrap/forms';
@import 'mixins/bootstrap/table-variants';
@import 'mixins/bootstrap/border-radius';
@import 'mixins/bootstrap/box-shadow';
@import 'mixins/bootstrap/gradients';
@import 'mixins/bootstrap/transition';
@import 'mixins/bootstrap/clearfix';
@import 'mixins/bootstrap/container';
@import 'mixins/bootstrap/grid';

View File

@@ -34,7 +34,7 @@
@each $name, $color in map.merge($theme-colors, $social-colors) {
--#{$prefix}#{$name}: #{$color};
--#{$prefix}#{$name}-rgb: #{to-rgb($color)};
--#{$prefix}#{$name}-fg: #{if(contrast-ratio($color) > $min-contrast-ratio, var(--#{$prefix}light), var(--#{$prefix}dark))};
--#{$prefix}#{$name}-fg: #{if(sass(contrast-ratio($color) > $min-contrast-ratio): var(--#{$prefix}light); else: var(--#{$prefix}dark))};
--#{$prefix}#{$name}-darken: #{theme-color-darker($color)};
--#{$prefix}#{$name}-darken: color-mix(in oklab, var(--#{$prefix}#{$name}), transparent 20%);
--#{$prefix}#{$name}-lt: #{theme-color-lighter($color)};
@@ -45,7 +45,7 @@
/** Gray colors */
@each $name, $color in $gray-colors {
--#{$prefix}#{$name}-fg: #{if(contrast-ratio($color, white) > $min-contrast-ratio, var(--#{$prefix}white), var(--#{$prefix}body-color))};
--#{$prefix}#{$name}-fg: #{if(sass(contrast-ratio($color, white) > $min-contrast-ratio): var(--#{$prefix}white); else: var(--#{$prefix}body-color))};
}
/** Spacers */
@@ -68,7 +68,7 @@
/** Shadows */
@each $name, $value in $box-shadows {
--#{$prefix}shadow#{if($name, '-#{$name}', '')}: #{$value};
--#{$prefix}shadow#{if(sass($name): '-#{$name}'; else: '')}: #{$value};
}
/** Border radiuses */
@@ -85,7 +85,7 @@
--#{$prefix}backdrop-opacity: #{$backdrop-opacity};
--#{$prefix}backdrop-bg: var(--#{$prefix}bg-surface-dark);
@each $name, $value in $backdrops {
--#{$prefix}backdrop-bg#{if($name, '-#{$name}', '')}: #{$value};
--#{$prefix}backdrop-bg#{if(sass($name): '-#{$name}'; else: '')}: #{$value};
}
--#{$prefix}backdrop-blur: #{$backdrop-blur};
--#{$prefix}backdrop-filter: blur(var(--#{$prefix}backdrop-blur));

View File

@@ -1,6 +1,6 @@
@use 'sass:map';
$negative-spacers-extra: if($enable-negative-margins, negativify-map(map.merge($spacers, $spacers-extra)), null);
$negative-spacers-extra: if(sass($enable-negative-margins): negativify-map(map.merge($spacers, $spacers-extra)); else: null);
$utilities: (
// Margin utilities

View File

@@ -20,7 +20,7 @@ $utilities-border-colors: map-loop(
$utilities: () !default;
$utilities: map-merge(
$utilities: map.merge(
(
'align': (
property: vertical-align,

View File

@@ -615,6 +615,8 @@ $link-color: $primary !default;
$link-shade-percentage: 20% !default;
$link-hover-color: shift-color($link-color, $link-shade-percentage) !default;
$link-hover-decoration: null !default;
$stretched-link-pseudo-element: after !default;
$stretched-link-z-index: 1 !default;
// Icon links
$icon-link-gap: 0.375rem !default;
@@ -1033,7 +1035,7 @@ $component-active-bg: $primary !default;
// Focus
$focus-ring-width: 0.25rem !default;
$focus-ring-opacity: 0.25 !default;
$focus-ring-color: color-mix(in srgb, var(--#{$prefix}primary) #{percentage($focus-ring-opacity)}, transparent) !default;
$focus-ring-color: color-mix(in srgb, var(--#{$prefix}primary) #{math.percentage($focus-ring-opacity)}, transparent) !default;
$focus-ring-blur: 0 !default;
$focus-ring-box-shadow: 0 0 $focus-ring-blur $focus-ring-width $focus-ring-color !default;
@@ -1708,7 +1710,7 @@ $table-active-bg: var(--#{$prefix}active-bg) !default;
$table-hover-color: $table-color !default;
$table-hover-bg-factor: 0.075 !default;
$table-hover-bg: color-mix(in srgb, var(--#{$prefix}emphasis-color) #{percentage($table-hover-bg-factor)}, transparent) !default;
$table-hover-bg: color-mix(in srgb, var(--#{$prefix}emphasis-color) #{math.percentage($table-hover-bg-factor)}, transparent) !default;
$table-caption-color: var(--#{$prefix}secondary-color) !default;
@@ -1972,7 +1974,7 @@ $form-validation-states: (
'icon': $form-feedback-icon-valid,
'tooltip-color': #fff,
'tooltip-bg-color': var(--#{$prefix}success),
'focus-box-shadow': 0 0 $input-btn-focus-blur $input-focus-width color-mix(in srgb, var(--#{$prefix}success) #{percentage($input-btn-focus-color-opacity)}, transparent),
'focus-box-shadow': 0 0 $input-btn-focus-blur $input-focus-width color-mix(in srgb, var(--#{$prefix}success) #{math.percentage($input-btn-focus-color-opacity)}, transparent),
'border-color': var(--#{$prefix}form-valid-border-color),
),
'invalid': (
@@ -1980,7 +1982,7 @@ $form-validation-states: (
'icon': $form-feedback-icon-invalid,
'tooltip-color': #fff,
'tooltip-bg-color': var(--#{$prefix}danger),
'focus-box-shadow': 0 0 $input-btn-focus-blur $input-focus-width color-mix(in srgb, var(--#{$prefix}danger) #{percentage($input-btn-focus-color-opacity)}, transparent),
'focus-box-shadow': 0 0 $input-btn-focus-blur $input-focus-width color-mix(in srgb, var(--#{$prefix}danger) #{math.percentage($input-btn-focus-color-opacity)}, transparent),
'border-color': var(--#{$prefix}form-invalid-border-color),
),
) !default;

View File

@@ -0,0 +1,147 @@
// Make the div behave like a button
.btn-group,
.btn-group-vertical {
position: relative;
display: inline-flex;
vertical-align: middle; // match .btn alignment given font-size hack above
> .btn {
position: relative;
flex: 1 1 auto;
}
// Bring the hover, focused, and "active" buttons to the front to overlay
// the borders properly
> .btn-check:checked + .btn,
> .btn-check:focus + .btn,
> .btn:hover,
> .btn:focus,
> .btn:active,
> .btn.active {
z-index: 1;
}
}
// Optional: Group multiple button groups together for a toolbar
.btn-toolbar {
display: flex;
flex-wrap: wrap;
justify-content: flex-start;
.input-group {
width: auto;
}
}
.btn-group {
@include border-radius($btn-border-radius);
// Prevent double borders when buttons are next to each other
> :not(.btn-check:first-child) + .btn,
> .btn-group:not(:first-child) {
margin-left: calc(-1 * #{$btn-border-width}); // stylelint-disable-line function-disallowed-list
}
// Reset rounded corners
> .btn:not(:last-child):not(.dropdown-toggle),
> .btn.dropdown-toggle-split:first-child,
> .btn-group:not(:last-child) > .btn {
@include border-end-radius(0);
}
// The left radius should be 0 if the button is:
// - the "third or more" child
// - the second child and the previous element isn't `.btn-check` (making it the first child visually)
// - part of a btn-group which isn't the first child
> .btn:nth-child(n + 3),
> :not(.btn-check) + .btn,
> .btn-group:not(:first-child) > .btn {
@include border-start-radius(0);
}
}
// Sizing
//
// Remix the default button sizing classes into new ones for easier manipulation.
.btn-group-sm > .btn { @extend .btn-sm; }
.btn-group-lg > .btn { @extend .btn-lg; }
//
// Split button dropdowns
//
.dropdown-toggle-split {
padding-right: $btn-padding-x * .75;
padding-left: $btn-padding-x * .75;
&::after,
.dropup &::after,
.dropend &::after {
margin-left: 0;
}
.dropstart &::before {
margin-right: 0;
}
}
.btn-sm + .dropdown-toggle-split {
padding-right: $btn-padding-x-sm * .75;
padding-left: $btn-padding-x-sm * .75;
}
.btn-lg + .dropdown-toggle-split {
padding-right: $btn-padding-x-lg * .75;
padding-left: $btn-padding-x-lg * .75;
}
// The clickable button for toggling the menu
// Set the same inset shadow as the :active state
.btn-group.show .dropdown-toggle {
@include box-shadow($btn-active-box-shadow);
// Show no shadow for `.btn-link` since it has no other button styles.
&.btn-link {
@include box-shadow(none);
}
}
//
// Vertical button groups
//
.btn-group-vertical {
flex-direction: column;
align-items: flex-start;
justify-content: center;
> .btn,
> .btn-group {
width: 100%;
}
> .btn:not(:first-child),
> .btn-group:not(:first-child) {
margin-top: calc(-1 * #{$btn-border-width}); // stylelint-disable-line function-disallowed-list
}
// Reset rounded corners
> .btn:not(:last-child):not(.dropdown-toggle),
> .btn-group:not(:last-child) > .btn {
@include border-bottom-radius(0);
}
// The top radius should be 0 if the button is:
// - the "third or more" child
// - the second child and the previous element isn't `.btn-check` (making it the first child visually)
// - part of a btn-group which isn't the first child
> .btn:nth-child(n + 3),
> :not(.btn-check) + .btn,
> .btn-group:not(:first-child) > .btn {
@include border-top-radius(0);
}
}

View File

@@ -0,0 +1,216 @@
//
// Base styles
//
.btn {
// scss-docs-start btn-css-vars
--#{$prefix}btn-padding-x: #{$btn-padding-x};
--#{$prefix}btn-padding-y: #{$btn-padding-y};
--#{$prefix}btn-font-family: #{$btn-font-family};
@include rfs($btn-font-size, --#{$prefix}btn-font-size);
--#{$prefix}btn-font-weight: #{$btn-font-weight};
--#{$prefix}btn-line-height: #{$btn-line-height};
--#{$prefix}btn-color: #{$btn-color};
--#{$prefix}btn-bg: transparent;
--#{$prefix}btn-border-width: #{$btn-border-width};
--#{$prefix}btn-border-color: transparent;
--#{$prefix}btn-border-radius: #{$btn-border-radius};
--#{$prefix}btn-hover-border-color: transparent;
--#{$prefix}btn-box-shadow: #{$btn-box-shadow};
--#{$prefix}btn-disabled-opacity: #{$btn-disabled-opacity};
--#{$prefix}btn-focus-box-shadow: 0 0 0 #{$btn-focus-width} rgba(var(--#{$prefix}btn-focus-shadow-rgb), .5);
// scss-docs-end btn-css-vars
display: inline-block;
padding: var(--#{$prefix}btn-padding-y) var(--#{$prefix}btn-padding-x);
font-family: var(--#{$prefix}btn-font-family);
@include font-size(var(--#{$prefix}btn-font-size));
font-weight: var(--#{$prefix}btn-font-weight);
line-height: var(--#{$prefix}btn-line-height);
color: var(--#{$prefix}btn-color);
text-align: center;
text-decoration: if(sass($link-decoration == none): null; else: none);
white-space: $btn-white-space;
vertical-align: middle;
cursor: if(sass($enable-button-pointers): pointer; else: null);
user-select: none;
border: var(--#{$prefix}btn-border-width) solid var(--#{$prefix}btn-border-color);
@include border-radius(var(--#{$prefix}btn-border-radius));
@include gradient-bg(var(--#{$prefix}btn-bg));
@include box-shadow(var(--#{$prefix}btn-box-shadow));
@include transition($btn-transition);
&:hover {
color: var(--#{$prefix}btn-hover-color);
text-decoration: if(sass($link-hover-decoration == underline): none; else: null);
background-color: var(--#{$prefix}btn-hover-bg);
border-color: var(--#{$prefix}btn-hover-border-color);
}
.btn-check + &:hover {
// override for the checkbox/radio buttons
color: var(--#{$prefix}btn-color);
background-color: var(--#{$prefix}btn-bg);
border-color: var(--#{$prefix}btn-border-color);
}
&:focus-visible {
color: var(--#{$prefix}btn-hover-color);
@include gradient-bg(var(--#{$prefix}btn-hover-bg));
border-color: var(--#{$prefix}btn-hover-border-color);
outline: 0;
// Avoid using mixin so we can pass custom focus shadow properly
@if $enable-shadows {
box-shadow: var(--#{$prefix}btn-box-shadow), var(--#{$prefix}btn-focus-box-shadow);
} @else {
box-shadow: var(--#{$prefix}btn-focus-box-shadow);
}
}
.btn-check:focus-visible + & {
border-color: var(--#{$prefix}btn-hover-border-color);
outline: 0;
// Avoid using mixin so we can pass custom focus shadow properly
@if $enable-shadows {
box-shadow: var(--#{$prefix}btn-box-shadow), var(--#{$prefix}btn-focus-box-shadow);
} @else {
box-shadow: var(--#{$prefix}btn-focus-box-shadow);
}
}
.btn-check:checked + &,
:not(.btn-check) + &:active,
&:first-child:active,
&.active,
&.show {
color: var(--#{$prefix}btn-active-color);
background-color: var(--#{$prefix}btn-active-bg);
// Remove CSS gradients if they're enabled
background-image: if(sass($enable-gradients): none; else: null);
border-color: var(--#{$prefix}btn-active-border-color);
@include box-shadow(var(--#{$prefix}btn-active-shadow));
&:focus-visible {
// Avoid using mixin so we can pass custom focus shadow properly
@if $enable-shadows {
box-shadow: var(--#{$prefix}btn-active-shadow), var(--#{$prefix}btn-focus-box-shadow);
} @else {
box-shadow: var(--#{$prefix}btn-focus-box-shadow);
}
}
}
.btn-check:checked:focus-visible + & {
// Avoid using mixin so we can pass custom focus shadow properly
@if $enable-shadows {
box-shadow: var(--#{$prefix}btn-active-shadow), var(--#{$prefix}btn-focus-box-shadow);
} @else {
box-shadow: var(--#{$prefix}btn-focus-box-shadow);
}
}
&:disabled,
&.disabled,
fieldset:disabled & {
color: var(--#{$prefix}btn-disabled-color);
pointer-events: none;
background-color: var(--#{$prefix}btn-disabled-bg);
background-image: if(sass($enable-gradients): none; else: null);
border-color: var(--#{$prefix}btn-disabled-border-color);
opacity: var(--#{$prefix}btn-disabled-opacity);
@include box-shadow(none);
}
}
//
// Alternate buttons
//
// scss-docs-start btn-variant-loops
@each $color, $value in $theme-colors {
.btn-#{$color} {
@if $color == "light" {
@include button-variant(
$value,
$value,
$hover-background: shade-color($value, $btn-hover-bg-shade-amount),
$hover-border: shade-color($value, $btn-hover-border-shade-amount),
$active-background: shade-color($value, $btn-active-bg-shade-amount),
$active-border: shade-color($value, $btn-active-border-shade-amount)
);
} @else if $color == "dark" {
@include button-variant(
$value,
$value,
$hover-background: tint-color($value, $btn-hover-bg-tint-amount),
$hover-border: tint-color($value, $btn-hover-border-tint-amount),
$active-background: tint-color($value, $btn-active-bg-tint-amount),
$active-border: tint-color($value, $btn-active-border-tint-amount)
);
} @else {
@include button-variant($value, $value);
}
}
}
@each $color, $value in $theme-colors {
.btn-outline-#{$color} {
@include button-outline-variant($value);
}
}
// scss-docs-end btn-variant-loops
//
// Link buttons
//
// Make a button look and behave like a link
.btn-link {
--#{$prefix}btn-font-weight: #{$font-weight-normal};
--#{$prefix}btn-color: #{$btn-link-color};
--#{$prefix}btn-bg: transparent;
--#{$prefix}btn-border-color: transparent;
--#{$prefix}btn-hover-color: #{$btn-link-hover-color};
--#{$prefix}btn-hover-border-color: transparent;
--#{$prefix}btn-active-color: #{$btn-link-hover-color};
--#{$prefix}btn-active-border-color: transparent;
--#{$prefix}btn-disabled-color: #{$btn-link-disabled-color};
--#{$prefix}btn-disabled-border-color: transparent;
--#{$prefix}btn-box-shadow: 0 0 0 #000; // Can't use `none` as keyword negates all values when used with multiple shadows
--#{$prefix}btn-focus-shadow-rgb: #{$btn-link-focus-shadow-rgb};
text-decoration: $link-decoration;
@if $enable-gradients {
background-image: none;
}
&:hover,
&:focus-visible {
text-decoration: $link-hover-decoration;
}
&:focus-visible {
color: var(--#{$prefix}btn-color);
}
&:hover {
color: var(--#{$prefix}btn-hover-color);
}
// No need for an active state here
}
//
// Button Sizes
//
.btn-lg {
@include button-size($btn-padding-y-lg, $btn-padding-x-lg, $btn-font-size-lg, $btn-border-radius-lg);
}
.btn-sm {
@include button-size($btn-padding-y-sm, $btn-padding-x-sm, $btn-font-size-sm, $btn-border-radius-sm);
}

View File

@@ -0,0 +1,238 @@
//
// Base styles
//
.card {
// scss-docs-start card-css-vars
--#{$prefix}card-spacer-y: #{$card-spacer-y};
--#{$prefix}card-spacer-x: #{$card-spacer-x};
--#{$prefix}card-title-spacer-y: #{$card-title-spacer-y};
--#{$prefix}card-title-color: #{$card-title-color};
--#{$prefix}card-subtitle-color: #{$card-subtitle-color};
--#{$prefix}card-border-width: #{$card-border-width};
--#{$prefix}card-border-color: #{$card-border-color};
--#{$prefix}card-border-radius: #{$card-border-radius};
--#{$prefix}card-box-shadow: #{$card-box-shadow};
--#{$prefix}card-inner-border-radius: #{$card-inner-border-radius};
--#{$prefix}card-cap-padding-y: #{$card-cap-padding-y};
--#{$prefix}card-cap-padding-x: #{$card-cap-padding-x};
--#{$prefix}card-cap-bg: #{$card-cap-bg};
--#{$prefix}card-cap-color: #{$card-cap-color};
--#{$prefix}card-height: #{$card-height};
--#{$prefix}card-color: #{$card-color};
--#{$prefix}card-bg: #{$card-bg};
--#{$prefix}card-img-overlay-padding: #{$card-img-overlay-padding};
--#{$prefix}card-group-margin: #{$card-group-margin};
// scss-docs-end card-css-vars
position: relative;
display: flex;
flex-direction: column;
min-width: 0; // See https://github.com/twbs/bootstrap/pull/22740#issuecomment-305868106
height: var(--#{$prefix}card-height);
color: var(--#{$prefix}body-color);
word-wrap: break-word;
background-color: var(--#{$prefix}card-bg);
background-clip: border-box;
border: var(--#{$prefix}card-border-width) solid var(--#{$prefix}card-border-color);
@include border-radius(var(--#{$prefix}card-border-radius));
@include box-shadow(var(--#{$prefix}card-box-shadow));
> hr {
margin-right: 0;
margin-left: 0;
}
> .list-group {
border-top: inherit;
border-bottom: inherit;
&:first-child {
border-top-width: 0;
@include border-top-radius(var(--#{$prefix}card-inner-border-radius));
}
&:last-child {
border-bottom-width: 0;
@include border-bottom-radius(var(--#{$prefix}card-inner-border-radius));
}
}
// Due to specificity of the above selector (`.card > .list-group`), we must
// use a child selector here to prevent double borders.
> .card-header + .list-group,
> .list-group + .card-footer {
border-top: 0;
}
}
.card-body {
// Enable `flex-grow: 1` for decks and groups so that card blocks take up
// as much space as possible, ensuring footers are aligned to the bottom.
flex: 1 1 auto;
padding: var(--#{$prefix}card-spacer-y) var(--#{$prefix}card-spacer-x);
color: var(--#{$prefix}card-color);
}
.card-title {
margin-bottom: var(--#{$prefix}card-title-spacer-y);
color: var(--#{$prefix}card-title-color);
}
.card-subtitle {
margin-top: calc(-.5 * var(--#{$prefix}card-title-spacer-y)); // stylelint-disable-line function-disallowed-list
margin-bottom: 0;
color: var(--#{$prefix}card-subtitle-color);
}
.card-text:last-child {
margin-bottom: 0;
}
.card-link {
&:hover {
text-decoration: if(sass($link-hover-decoration == underline): none; else: null);
}
+ .card-link {
margin-left: var(--#{$prefix}card-spacer-x);
}
}
//
// Optional textual caps
//
.card-header {
padding: var(--#{$prefix}card-cap-padding-y) var(--#{$prefix}card-cap-padding-x);
margin-bottom: 0; // Removes the default margin-bottom of <hN>
color: var(--#{$prefix}card-cap-color);
background-color: var(--#{$prefix}card-cap-bg);
border-bottom: var(--#{$prefix}card-border-width) solid var(--#{$prefix}card-border-color);
&:first-child {
@include border-radius(var(--#{$prefix}card-inner-border-radius) var(--#{$prefix}card-inner-border-radius) 0 0);
}
}
.card-footer {
padding: var(--#{$prefix}card-cap-padding-y) var(--#{$prefix}card-cap-padding-x);
color: var(--#{$prefix}card-cap-color);
background-color: var(--#{$prefix}card-cap-bg);
border-top: var(--#{$prefix}card-border-width) solid var(--#{$prefix}card-border-color);
&:last-child {
@include border-radius(0 0 var(--#{$prefix}card-inner-border-radius) var(--#{$prefix}card-inner-border-radius));
}
}
//
// Header navs
//
.card-header-tabs {
margin-right: calc(-.5 * var(--#{$prefix}card-cap-padding-x)); // stylelint-disable-line function-disallowed-list
margin-bottom: calc(-1 * var(--#{$prefix}card-cap-padding-y)); // stylelint-disable-line function-disallowed-list
margin-left: calc(-.5 * var(--#{$prefix}card-cap-padding-x)); // stylelint-disable-line function-disallowed-list
border-bottom: 0;
.nav-link.active {
background-color: var(--#{$prefix}card-bg);
border-bottom-color: var(--#{$prefix}card-bg);
}
}
.card-header-pills {
margin-right: calc(-.5 * var(--#{$prefix}card-cap-padding-x)); // stylelint-disable-line function-disallowed-list
margin-left: calc(-.5 * var(--#{$prefix}card-cap-padding-x)); // stylelint-disable-line function-disallowed-list
}
// Card image
.card-img-overlay {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
padding: var(--#{$prefix}card-img-overlay-padding);
@include border-radius(var(--#{$prefix}card-inner-border-radius));
}
.card-img,
.card-img-top,
.card-img-bottom {
width: 100%; // Required because we use flexbox and this inherently applies align-self: stretch
}
.card-img,
.card-img-top {
@include border-top-radius(var(--#{$prefix}card-inner-border-radius));
}
.card-img,
.card-img-bottom {
@include border-bottom-radius(var(--#{$prefix}card-inner-border-radius));
}
//
// Card groups
//
.card-group {
// The child selector allows nested `.card` within `.card-group`
// to display properly.
> .card {
margin-bottom: var(--#{$prefix}card-group-margin);
}
@include media-breakpoint-up(sm) {
display: flex;
flex-flow: row wrap;
// The child selector allows nested `.card` within `.card-group`
// to display properly.
> .card {
flex: 1 0 0;
margin-bottom: 0;
+ .card {
margin-left: 0;
border-left: 0;
}
// Handle rounded corners
@if $enable-rounded {
&:not(:last-child) {
@include border-end-radius(0);
> .card-img-top,
> .card-header {
// stylelint-disable-next-line property-disallowed-list
border-top-right-radius: 0;
}
> .card-img-bottom,
> .card-footer {
// stylelint-disable-next-line property-disallowed-list
border-bottom-right-radius: 0;
}
}
&:not(:first-child) {
@include border-start-radius(0);
> .card-img-top,
> .card-header {
// stylelint-disable-next-line property-disallowed-list
border-top-left-radius: 0;
}
> .card-img-bottom,
> .card-footer {
// stylelint-disable-next-line property-disallowed-list
border-bottom-left-radius: 0;
}
}
}
}
}
}

View File

@@ -0,0 +1,228 @@
// Notes on the classes:
//
// 1. .carousel.pointer-event should ideally be pan-y (to allow for users to scroll vertically)
// even when their scroll action started on a carousel, but for compatibility (with Firefox)
// we're preventing all actions instead
// 2. The .carousel-item-start and .carousel-item-end is used to indicate where
// the active slide is heading.
// 3. .active.carousel-item is the current slide.
// 4. .active.carousel-item-start and .active.carousel-item-end is the current
// slide in its in-transition state. Only one of these occurs at a time.
// 5. .carousel-item-next.carousel-item-start and .carousel-item-prev.carousel-item-end
// is the upcoming slide in transition.
.carousel {
position: relative;
}
.carousel.pointer-event {
touch-action: pan-y;
}
.carousel-inner {
position: relative;
width: 100%;
overflow: hidden;
@include clearfix();
}
.carousel-item {
position: relative;
display: none;
float: left;
width: 100%;
margin-right: -100%;
backface-visibility: hidden;
@include transition($carousel-transition);
}
.carousel-item.active,
.carousel-item-next,
.carousel-item-prev {
display: block;
}
.carousel-item-next:not(.carousel-item-start),
.active.carousel-item-end {
transform: translateX(100%);
}
.carousel-item-prev:not(.carousel-item-end),
.active.carousel-item-start {
transform: translateX(-100%);
}
//
// Alternate transitions
//
.carousel-fade {
.carousel-item {
opacity: 0;
transition-property: opacity;
transform: none;
}
.carousel-item.active,
.carousel-item-next.carousel-item-start,
.carousel-item-prev.carousel-item-end {
z-index: 1;
opacity: 1;
}
.active.carousel-item-start,
.active.carousel-item-end {
z-index: 0;
opacity: 0;
@include transition(opacity 0s $carousel-transition-duration);
}
}
//
// Left/right controls for nav
//
.carousel-control-prev,
.carousel-control-next {
position: absolute;
top: 0;
bottom: 0;
z-index: 1;
// Use flex for alignment (1-3)
display: flex; // 1. allow flex styles
align-items: center; // 2. vertically center contents
justify-content: center; // 3. horizontally center contents
width: $carousel-control-width;
padding: 0;
color: $carousel-control-color;
text-align: center;
background: none;
filter: var(--#{$prefix}carousel-control-icon-filter);
border: 0;
opacity: $carousel-control-opacity;
@include transition($carousel-control-transition);
// Hover/focus state
&:hover,
&:focus {
color: $carousel-control-color;
text-decoration: none;
outline: 0;
opacity: $carousel-control-hover-opacity;
}
}
.carousel-control-prev {
left: 0;
background-image: if(sass($enable-gradients): linear-gradient(90deg, rgba($black, .25), rgba($black, .001)); else: null);
}
.carousel-control-next {
right: 0;
background-image: if(sass($enable-gradients): linear-gradient(270deg, rgba($black, .25), rgba($black, .001)); else: null);
}
// Icons for within
.carousel-control-prev-icon,
.carousel-control-next-icon {
display: inline-block;
width: $carousel-control-icon-width;
height: $carousel-control-icon-width;
background-repeat: no-repeat;
background-position: 50%;
background-size: 100% 100%;
}
.carousel-control-prev-icon {
background-image: escape-svg($carousel-control-prev-icon-bg) #{"/*rtl:" + escape-svg($carousel-control-next-icon-bg) + "*/"};
}
.carousel-control-next-icon {
background-image: escape-svg($carousel-control-next-icon-bg) #{"/*rtl:" + escape-svg($carousel-control-prev-icon-bg) + "*/"};
}
// Optional indicator pips/controls
//
// Add a container (such as a list) with the following class and add an item (ideally a focusable control,
// like a button) with data-bs-target for each slide your carousel holds.
.carousel-indicators {
position: absolute;
right: 0;
bottom: 0;
left: 0;
z-index: 2;
display: flex;
justify-content: center;
padding: 0;
// Use the .carousel-control's width as margin so we don't overlay those
margin-right: $carousel-control-width;
margin-bottom: 1rem;
margin-left: $carousel-control-width;
[data-bs-target],
[data-target] {
box-sizing: content-box;
flex: 0 1 auto;
width: $carousel-indicator-width;
height: $carousel-indicator-height;
padding: 0;
margin-right: $carousel-indicator-spacer;
margin-left: $carousel-indicator-spacer;
text-indent: -999px;
cursor: pointer;
background-color: var(--#{$prefix}carousel-indicator-active-bg);
background-clip: padding-box;
border: 0;
// Use transparent borders to increase the hit area by 10px on top and bottom.
border-top: $carousel-indicator-hit-area-height solid transparent;
border-bottom: $carousel-indicator-hit-area-height solid transparent;
opacity: $carousel-indicator-opacity;
@include transition($carousel-indicator-transition);
}
.active {
opacity: $carousel-indicator-active-opacity;
}
}
// Optional captions
//
//
.carousel-caption {
position: absolute;
right: (100% - $carousel-caption-width) * .5;
bottom: $carousel-caption-spacer;
left: (100% - $carousel-caption-width) * .5;
padding-top: $carousel-caption-padding-y;
padding-bottom: $carousel-caption-padding-y;
color: var(--#{$prefix}carousel-caption-color);
text-align: center;
}
// Dark mode carousel
@mixin carousel-dark() {
--#{$prefix}carousel-indicator-active-bg: #{$carousel-indicator-active-bg-dark};
--#{$prefix}carousel-caption-color: #{$carousel-caption-color-dark};
--#{$prefix}carousel-control-icon-filter: #{$carousel-control-icon-filter-dark};
}
.carousel-dark {
@include carousel-dark();
}
:root,
[data-bs-theme="light"],
[data-theme="light"] {
--#{$prefix}carousel-indicator-active-bg: #{$carousel-indicator-active-bg};
--#{$prefix}carousel-caption-color: #{$carousel-caption-color};
--#{$prefix}carousel-control-icon-filter: #{$carousel-control-icon-filter};
}
@if $enable-dark-mode {
@include color-mode(dark, true) {
@include carousel-dark();
}
}

View File

@@ -0,0 +1,41 @@
// Container widths
//
// Set the container width, and override it for fixed navbars in media queries.
@if $enable-container-classes {
// Single container class with breakpoint max-widths
.container,
// 100% wide container at all breakpoints
.container-fluid {
@include make-container();
}
// Responsive containers that are 100% wide until a breakpoint
@each $breakpoint, $container-max-width in $container-max-widths {
.container-#{$breakpoint} {
@extend .container-fluid;
}
@include media-breakpoint-up($breakpoint, $grid-breakpoints) {
%responsive-container-#{$breakpoint} {
max-width: $container-max-width;
}
// Extend each breakpoint which is smaller or equal to the current breakpoint
$extend-breakpoint: true;
@each $name, $width in $grid-breakpoints {
@if ($extend-breakpoint) {
.container#{breakpoint-infix($name, $grid-breakpoints)} {
@extend %responsive-container-#{$breakpoint};
}
// Once the current breakpoint is reached, stop extending
@if ($breakpoint == $name) {
$extend-breakpoint: false;
}
}
}
}
}
}

View File

@@ -0,0 +1,258 @@
@use "sass:map";
// The dropdown wrapper (`<div>`)
.dropup,
.dropend,
.dropdown,
.dropstart,
.dropup-center,
.dropdown-center {
position: relative;
}
.dropdown-toggle {
white-space: nowrap;
// Generate the caret automatically
@include caret();
}
// The dropdown menu
.dropdown-menu {
// scss-docs-start dropdown-css-vars
--#{$prefix}dropdown-zindex: #{$zindex-dropdown};
--#{$prefix}dropdown-min-width: #{$dropdown-min-width};
--#{$prefix}dropdown-padding-x: #{$dropdown-padding-x};
--#{$prefix}dropdown-padding-y: #{$dropdown-padding-y};
--#{$prefix}dropdown-spacer: #{$dropdown-spacer};
@include rfs($dropdown-font-size, --#{$prefix}dropdown-font-size);
--#{$prefix}dropdown-color: #{$dropdown-color};
--#{$prefix}dropdown-bg: #{$dropdown-bg};
--#{$prefix}dropdown-border-color: #{$dropdown-border-color};
--#{$prefix}dropdown-border-radius: #{$dropdown-border-radius};
--#{$prefix}dropdown-border-width: #{$dropdown-border-width};
--#{$prefix}dropdown-inner-border-radius: #{$dropdown-inner-border-radius};
--#{$prefix}dropdown-divider-bg: #{$dropdown-divider-bg};
--#{$prefix}dropdown-divider-margin-y: #{$dropdown-divider-margin-y};
--#{$prefix}dropdown-box-shadow: #{$dropdown-box-shadow};
--#{$prefix}dropdown-link-color: #{$dropdown-link-color};
--#{$prefix}dropdown-link-hover-color: #{$dropdown-link-hover-color};
--#{$prefix}dropdown-link-hover-bg: #{$dropdown-link-hover-bg};
--#{$prefix}dropdown-link-active-color: #{$dropdown-link-active-color};
--#{$prefix}dropdown-link-active-bg: #{$dropdown-link-active-bg};
--#{$prefix}dropdown-link-disabled-color: #{$dropdown-link-disabled-color};
--#{$prefix}dropdown-item-padding-x: #{$dropdown-item-padding-x};
--#{$prefix}dropdown-item-padding-y: #{$dropdown-item-padding-y};
--#{$prefix}dropdown-header-color: #{$dropdown-header-color};
--#{$prefix}dropdown-header-padding-x: #{$dropdown-header-padding-x};
--#{$prefix}dropdown-header-padding-y: #{$dropdown-header-padding-y};
// scss-docs-end dropdown-css-vars
position: absolute;
z-index: var(--#{$prefix}dropdown-zindex);
display: none; // none by default, but block on "open" of the menu
min-width: var(--#{$prefix}dropdown-min-width);
padding: var(--#{$prefix}dropdown-padding-y) var(--#{$prefix}dropdown-padding-x);
margin: 0; // Override default margin of ul
@include font-size(var(--#{$prefix}dropdown-font-size));
color: var(--#{$prefix}dropdown-color);
text-align: left; // Ensures proper alignment if parent has it changed (e.g., modal footer)
list-style: none;
background-color: var(--#{$prefix}dropdown-bg);
background-clip: padding-box;
border: var(--#{$prefix}dropdown-border-width) solid var(--#{$prefix}dropdown-border-color);
@include border-radius(var(--#{$prefix}dropdown-border-radius));
@include box-shadow(var(--#{$prefix}dropdown-box-shadow));
&[data-bs-popper],
&[data-tblr-popper] {
top: 100%;
left: 0;
margin-top: var(--#{$prefix}dropdown-spacer);
}
@if $dropdown-padding-y == 0 {
> .dropdown-item:first-child,
> li:first-child .dropdown-item {
@include border-top-radius(var(--#{$prefix}dropdown-inner-border-radius));
}
> .dropdown-item:last-child,
> li:last-child .dropdown-item {
@include border-bottom-radius(var(--#{$prefix}dropdown-inner-border-radius));
}
}
}
// scss-docs-start responsive-breakpoints
// We deliberately hardcode the `bs-` prefix because we check
// this custom property in JS to determine Popper's positioning
@each $breakpoint in map.keys($grid-breakpoints) {
@include media-breakpoint-up($breakpoint) {
$infix: breakpoint-infix($breakpoint, $grid-breakpoints);
.dropdown-menu#{$infix}-start {
--bs-position: start;
&[data-bs-popper],
&[data-tblr-popper] {
right: auto;
left: 0;
}
}
.dropdown-menu#{$infix}-end {
--bs-position: end;
&[data-bs-popper],
&[data-tblr-popper] {
right: 0;
left: auto;
}
}
}
}
// scss-docs-end responsive-breakpoints
// Allow for dropdowns to go bottom up (aka, dropup-menu)
// Just add .dropup after the standard .dropdown class and you're set.
.dropup {
.dropdown-menu[data-bs-popper],
.dropdown-menu[data-tblr-popper] {
top: auto;
bottom: 100%;
margin-top: 0;
margin-bottom: var(--#{$prefix}dropdown-spacer);
}
.dropdown-toggle {
@include caret(up);
}
}
.dropend {
.dropdown-menu[data-bs-popper],
.dropdown-menu[data-tblr-popper] {
top: 0;
right: auto;
left: 100%;
margin-top: 0;
margin-left: var(--#{$prefix}dropdown-spacer);
}
.dropdown-toggle {
@include caret(end);
&::after {
vertical-align: 0;
}
}
}
.dropstart {
.dropdown-menu[data-bs-popper],
.dropdown-menu[data-tblr-popper] {
top: 0;
right: 100%;
left: auto;
margin-top: 0;
margin-right: var(--#{$prefix}dropdown-spacer);
}
.dropdown-toggle {
@include caret(start);
&::before {
vertical-align: 0;
}
}
}
// Dividers (basically an `<hr>`) within the dropdown
.dropdown-divider {
height: 0;
margin: var(--#{$prefix}dropdown-divider-margin-y) 0;
overflow: hidden;
border-top: 1px solid var(--#{$prefix}dropdown-divider-bg);
opacity: 1; // Revisit in v6 to de-dupe styles that conflict with <hr> element
}
// Links, buttons, and more within the dropdown menu
//
// `<button>`-specific styles are denoted with `// For <button>s`
.dropdown-item {
display: block;
width: 100%; // For `<button>`s
padding: var(--#{$prefix}dropdown-item-padding-y) var(--#{$prefix}dropdown-item-padding-x);
clear: both;
font-weight: $font-weight-normal;
color: var(--#{$prefix}dropdown-link-color);
text-align: inherit; // For `<button>`s
text-decoration: if(sass($link-decoration == none): null; else: none);
white-space: nowrap; // prevent links from randomly breaking onto new lines
background-color: transparent; // For `<button>`s
border: 0; // For `<button>`s
@include border-radius(var(--#{$prefix}dropdown-item-border-radius, 0));
&:hover,
&:focus {
color: var(--#{$prefix}dropdown-link-hover-color);
text-decoration: if(sass($link-hover-decoration == underline): none; else: null);
@include gradient-bg(var(--#{$prefix}dropdown-link-hover-bg));
}
&.active,
&:active {
color: var(--#{$prefix}dropdown-link-active-color);
text-decoration: none;
@include gradient-bg(var(--#{$prefix}dropdown-link-active-bg));
}
&.disabled,
&:disabled {
color: var(--#{$prefix}dropdown-link-disabled-color);
pointer-events: none;
background-color: transparent;
// Remove CSS gradients if they're enabled
background-image: if(sass($enable-gradients): none; else: null);
}
}
.dropdown-menu.show {
display: block;
}
// Dropdown section headers
.dropdown-header {
display: block;
padding: var(--#{$prefix}dropdown-header-padding-y) var(--#{$prefix}dropdown-header-padding-x);
margin-bottom: 0; // for use with heading elements
@include font-size($font-size-sm);
color: var(--#{$prefix}dropdown-header-color);
white-space: nowrap; // as with > li > a
}
// Dropdown text
.dropdown-item-text {
display: block;
padding: var(--#{$prefix}dropdown-item-padding-y) var(--#{$prefix}dropdown-item-padding-x);
color: var(--#{$prefix}dropdown-link-color);
}
// Dark dropdowns
.dropdown-menu-dark {
// scss-docs-start dropdown-dark-css-vars
--#{$prefix}dropdown-color: #{$dropdown-dark-color};
--#{$prefix}dropdown-bg: #{$dropdown-dark-bg};
--#{$prefix}dropdown-border-color: #{$dropdown-dark-border-color};
--#{$prefix}dropdown-box-shadow: #{$dropdown-dark-box-shadow};
--#{$prefix}dropdown-link-color: #{$dropdown-dark-link-color};
--#{$prefix}dropdown-link-hover-color: #{$dropdown-dark-link-hover-color};
--#{$prefix}dropdown-divider-bg: #{$dropdown-dark-divider-bg};
--#{$prefix}dropdown-link-hover-bg: #{$dropdown-dark-link-hover-bg};
--#{$prefix}dropdown-link-active-color: #{$dropdown-dark-link-active-color};
--#{$prefix}dropdown-link-active-bg: #{$dropdown-dark-link-active-bg};
--#{$prefix}dropdown-link-disabled-color: #{$dropdown-dark-link-disabled-color};
--#{$prefix}dropdown-header-color: #{$dropdown-dark-header-color};
// scss-docs-end dropdown-dark-css-vars
}

View File

@@ -0,0 +1,9 @@
@import "forms/labels";
@import "forms/form-text";
@import "forms/form-control";
@import "forms/form-select";
@import "forms/form-check";
@import "forms/form-range";
@import "forms/floating-labels";
@import "forms/input-group";
@import "forms/validation";

View File

@@ -0,0 +1,39 @@
// Row
//
// Rows contain your columns.
:root {
@each $name, $value in $grid-breakpoints {
--#{$prefix}breakpoint-#{$name}: #{$value};
}
}
@if $enable-grid-classes {
.row {
@include make-row();
> * {
@include make-col-ready();
}
}
}
@if $enable-cssgrid {
.grid {
display: grid;
grid-template-rows: repeat(var(--#{$prefix}rows, 1), 1fr);
grid-template-columns: repeat(var(--#{$prefix}columns, #{$grid-columns}), 1fr);
gap: var(--#{$prefix}gap, #{$grid-gutter-width});
@include make-cssgrid();
}
}
// Columns
//
// Common styles for small and large grid columns
@if $enable-grid-classes {
@include make-grid-columns();
}

View File

@@ -0,0 +1,42 @@
// Responsive images (ensure images don't scale beyond their parents)
//
// This is purposefully opt-in via an explicit class rather than being the default for all `<img>`s.
// We previously tried the "images are responsive by default" approach in Bootstrap v2,
// and abandoned it in Bootstrap v3 because it breaks lots of third-party widgets (including Google Maps)
// which weren't expecting the images within themselves to be involuntarily resized.
// See also https://github.com/twbs/bootstrap/issues/18178
.img-fluid {
@include img-fluid();
}
// Image thumbnails
.img-thumbnail {
padding: $thumbnail-padding;
background-color: $thumbnail-bg;
border: $thumbnail-border-width solid $thumbnail-border-color;
@include border-radius($thumbnail-border-radius);
@include box-shadow($thumbnail-box-shadow);
// Keep them at most 100% wide
@include img-fluid();
}
//
// Figures
//
.figure {
// Ensures the caption's text aligns with the image.
display: inline-block;
}
.figure-img {
margin-bottom: $spacer * .5;
line-height: 1;
}
.figure-caption {
@include font-size($figure-caption-font-size);
color: $figure-caption-color;
}

View File

@@ -0,0 +1,201 @@
@use "sass:map";
// Base class
//
// Easily usable on <ul>, <ol>, or <div>.
.list-group {
// scss-docs-start list-group-css-vars
--#{$prefix}list-group-color: #{$list-group-color};
--#{$prefix}list-group-bg: #{$list-group-bg};
--#{$prefix}list-group-border-color: #{$list-group-border-color};
--#{$prefix}list-group-border-width: #{$list-group-border-width};
--#{$prefix}list-group-border-radius: #{$list-group-border-radius};
--#{$prefix}list-group-item-padding-x: #{$list-group-item-padding-x};
--#{$prefix}list-group-item-padding-y: #{$list-group-item-padding-y};
--#{$prefix}list-group-action-color: #{$list-group-action-color};
--#{$prefix}list-group-action-hover-color: #{$list-group-action-hover-color};
--#{$prefix}list-group-action-hover-bg: #{$list-group-hover-bg};
--#{$prefix}list-group-action-active-color: #{$list-group-action-active-color};
--#{$prefix}list-group-action-active-bg: #{$list-group-action-active-bg};
--#{$prefix}list-group-disabled-color: #{$list-group-disabled-color};
--#{$prefix}list-group-disabled-bg: #{$list-group-disabled-bg};
--#{$prefix}list-group-active-color: #{$list-group-active-color};
--#{$prefix}list-group-active-bg: #{$list-group-active-bg};
--#{$prefix}list-group-active-border-color: #{$list-group-active-border-color};
// scss-docs-end list-group-css-vars
display: flex;
flex-direction: column;
// No need to set list-style: none; since .list-group-item is block level
padding-left: 0; // reset padding because ul and ol
margin-bottom: 0;
@include border-radius(var(--#{$prefix}list-group-border-radius));
}
.list-group-numbered {
list-style-type: none;
counter-reset: section;
> .list-group-item::before {
// Increments only this instance of the section counter
content: counters(section, ".") ". ";
counter-increment: section;
}
}
// Individual list items
//
// Use on `li`s or `div`s within the `.list-group` parent.
.list-group-item {
position: relative;
display: block;
padding: var(--#{$prefix}list-group-item-padding-y) var(--#{$prefix}list-group-item-padding-x);
color: var(--#{$prefix}list-group-color);
text-decoration: if(sass($link-decoration == none): null; else: none);
background-color: var(--#{$prefix}list-group-bg);
border: var(--#{$prefix}list-group-border-width) solid var(--#{$prefix}list-group-border-color);
&:first-child {
@include border-top-radius(inherit);
}
&:last-child {
@include border-bottom-radius(inherit);
}
&.disabled,
&:disabled {
color: var(--#{$prefix}list-group-disabled-color);
pointer-events: none;
background-color: var(--#{$prefix}list-group-disabled-bg);
}
// Include both here for `<a>`s and `<button>`s
&.active {
z-index: 2; // Place active items above their siblings for proper border styling
color: var(--#{$prefix}list-group-active-color);
background-color: var(--#{$prefix}list-group-active-bg);
border-color: var(--#{$prefix}list-group-active-border-color);
}
// stylelint-disable-next-line scss/selector-no-redundant-nesting-selector
& + .list-group-item {
border-top-width: 0;
&.active {
margin-top: calc(-1 * var(--#{$prefix}list-group-border-width)); // stylelint-disable-line function-disallowed-list
border-top-width: var(--#{$prefix}list-group-border-width);
}
}
}
// Interactive list items
//
// Use anchor or button elements instead of `li`s or `div`s to create interactive
// list items. Includes an extra `.active` modifier class for selected items.
.list-group-item-action {
width: 100%; // For `<button>`s (anchors become 100% by default though)
color: var(--#{$prefix}list-group-action-color);
text-align: inherit; // For `<button>`s (anchors inherit)
&:not(.active) {
// Hover state
&:hover,
&:focus {
z-index: 1; // Place hover/focus items above their siblings for proper border styling
color: var(--#{$prefix}list-group-action-hover-color);
text-decoration: none;
background-color: var(--#{$prefix}list-group-action-hover-bg);
}
&:active {
color: var(--#{$prefix}list-group-action-active-color);
background-color: var(--#{$prefix}list-group-action-active-bg);
}
}
}
// Horizontal
//
// Change the layout of list group items from vertical (default) to horizontal.
@each $breakpoint in map.keys($grid-breakpoints) {
@include media-breakpoint-up($breakpoint) {
$infix: breakpoint-infix($breakpoint, $grid-breakpoints);
.list-group-horizontal#{$infix} {
flex-direction: row;
> .list-group-item {
&:first-child:not(:last-child) {
@include border-bottom-start-radius(var(--#{$prefix}list-group-border-radius));
@include border-top-end-radius(0);
}
&:last-child:not(:first-child) {
@include border-top-end-radius(var(--#{$prefix}list-group-border-radius));
@include border-bottom-start-radius(0);
}
&.active {
margin-top: 0;
}
+ .list-group-item {
border-top-width: var(--#{$prefix}list-group-border-width);
border-left-width: 0;
&.active {
margin-left: calc(-1 * var(--#{$prefix}list-group-border-width)); // stylelint-disable-line function-disallowed-list
border-left-width: var(--#{$prefix}list-group-border-width);
}
}
}
}
}
}
// Flush list items
//
// Remove borders and border-radius to keep list group items edge-to-edge. Most
// useful within other components (e.g., cards).
.list-group-flush {
@include border-radius(0);
> .list-group-item {
border-width: 0 0 var(--#{$prefix}list-group-border-width);
&:last-child {
border-bottom-width: 0;
}
}
}
// scss-docs-start list-group-modifiers
// List group contextual variants
//
// Add modifier classes to change text and background color on individual items.
// Organizationally, this must come after the `:hover` states.
@each $state in map.keys($theme-colors) {
.list-group-item-#{$state} {
--#{$prefix}list-group-color: var(--#{$prefix}#{$state}-text-emphasis);
--#{$prefix}list-group-bg: var(--#{$prefix}#{$state}-bg-subtle);
--#{$prefix}list-group-border-color: var(--#{$prefix}#{$state}-border-subtle);
--#{$prefix}list-group-action-hover-color: var(--#{$prefix}emphasis-color);
--#{$prefix}list-group-action-hover-bg: var(--#{$prefix}#{$state}-border-subtle);
--#{$prefix}list-group-action-active-color: var(--#{$prefix}emphasis-color);
--#{$prefix}list-group-action-active-bg: var(--#{$prefix}#{$state}-border-subtle);
--#{$prefix}list-group-active-color: var(--#{$prefix}#{$state}-bg-subtle);
--#{$prefix}list-group-active-bg: var(--#{$prefix}#{$state}-text-emphasis);
--#{$prefix}list-group-active-border-color: var(--#{$prefix}#{$state}-text-emphasis);
}
}
// scss-docs-end list-group-modifiers

View File

@@ -0,0 +1,242 @@
// stylelint-disable function-disallowed-list
@use "sass:map";
// .modal-open - body class for killing the scroll
// .modal - container to scroll within
// .modal-dialog - positioning shell for the actual modal
// .modal-content - actual modal w/ bg and corners and stuff
// Container that the modal scrolls within
.modal {
// scss-docs-start modal-css-vars
--#{$prefix}modal-zindex: #{$zindex-modal};
--#{$prefix}modal-width: #{$modal-md};
--#{$prefix}modal-padding: #{$modal-inner-padding};
--#{$prefix}modal-margin: #{$modal-dialog-margin};
--#{$prefix}modal-color: #{$modal-content-color};
--#{$prefix}modal-bg: #{$modal-content-bg};
--#{$prefix}modal-border-color: #{$modal-content-border-color};
--#{$prefix}modal-border-width: #{$modal-content-border-width};
--#{$prefix}modal-border-radius: #{$modal-content-border-radius};
--#{$prefix}modal-box-shadow: #{$modal-content-box-shadow-xs};
--#{$prefix}modal-inner-border-radius: #{$modal-content-inner-border-radius};
--#{$prefix}modal-header-padding-x: #{$modal-header-padding-x};
--#{$prefix}modal-header-padding-y: #{$modal-header-padding-y};
--#{$prefix}modal-header-padding: #{$modal-header-padding}; // Todo in v6: Split this padding into x and y
--#{$prefix}modal-header-border-color: #{$modal-header-border-color};
--#{$prefix}modal-header-border-width: #{$modal-header-border-width};
--#{$prefix}modal-title-line-height: #{$modal-title-line-height};
--#{$prefix}modal-footer-gap: #{$modal-footer-margin-between};
--#{$prefix}modal-footer-bg: #{$modal-footer-bg};
--#{$prefix}modal-footer-border-color: #{$modal-footer-border-color};
--#{$prefix}modal-footer-border-width: #{$modal-footer-border-width};
// scss-docs-end modal-css-vars
position: fixed;
top: 0;
left: 0;
z-index: var(--#{$prefix}modal-zindex);
display: none;
width: 100%;
height: 100%;
overflow-x: hidden;
overflow-y: auto;
// Prevent Chrome on Windows from adding a focus outline. For details, see
// https://github.com/twbs/bootstrap/pull/10951.
outline: 0;
// We deliberately don't use `-webkit-overflow-scrolling: touch;` due to a
// gnarly iOS Safari bug: https://bugs.webkit.org/show_bug.cgi?id=158342
// See also https://github.com/twbs/bootstrap/issues/17695
}
// Shell div to position the modal with bottom padding
.modal-dialog {
position: relative;
width: auto;
margin: var(--#{$prefix}modal-margin);
// allow clicks to pass through for custom click handling to close modal
pointer-events: none;
// When fading in the modal, animate it to slide down
.modal.fade & {
transform: $modal-fade-transform;
@include transition($modal-transition);
}
.modal.show & {
transform: $modal-show-transform;
}
// When trying to close, animate focus to scale
.modal.modal-static & {
transform: $modal-scale-transform;
}
}
.modal-dialog-scrollable {
height: calc(100% - var(--#{$prefix}modal-margin) * 2);
.modal-content {
max-height: 100%;
overflow: hidden;
}
.modal-body {
overflow-y: auto;
}
}
.modal-dialog-centered {
display: flex;
align-items: center;
min-height: calc(100% - var(--#{$prefix}modal-margin) * 2);
}
// Actual modal
.modal-content {
position: relative;
display: flex;
flex-direction: column;
width: 100%; // Ensure `.modal-content` extends the full width of the parent `.modal-dialog`
// counteract the pointer-events: none; in the .modal-dialog
color: var(--#{$prefix}modal-color);
pointer-events: auto;
background-color: var(--#{$prefix}modal-bg);
background-clip: padding-box;
border: var(--#{$prefix}modal-border-width) solid var(--#{$prefix}modal-border-color);
@include border-radius(var(--#{$prefix}modal-border-radius));
@include box-shadow(var(--#{$prefix}modal-box-shadow));
// Remove focus outline from opened modal
outline: 0;
}
// Modal background
.modal-backdrop {
// scss-docs-start modal-backdrop-css-vars
--#{$prefix}backdrop-zindex: #{$zindex-modal-backdrop};
--#{$prefix}backdrop-bg: #{$modal-backdrop-bg};
--#{$prefix}backdrop-opacity: #{$modal-backdrop-opacity};
// scss-docs-end modal-backdrop-css-vars
@include overlay-backdrop(var(--#{$prefix}backdrop-zindex), var(--#{$prefix}backdrop-bg), var(--#{$prefix}backdrop-opacity));
}
// Modal header
// Top section of the modal w/ title and dismiss
.modal-header {
display: flex;
flex-shrink: 0;
align-items: center;
padding: var(--#{$prefix}modal-header-padding);
border-bottom: var(--#{$prefix}modal-header-border-width) solid var(--#{$prefix}modal-header-border-color);
@include border-top-radius(var(--#{$prefix}modal-inner-border-radius));
.btn-close {
padding: calc(var(--#{$prefix}modal-header-padding-y) * .5) calc(var(--#{$prefix}modal-header-padding-x) * .5);
// Split properties to avoid invalid calc() function if value is 0
margin-top: calc(-.5 * var(--#{$prefix}modal-header-padding-y));
margin-right: calc(-.5 * var(--#{$prefix}modal-header-padding-x));
margin-bottom: calc(-.5 * var(--#{$prefix}modal-header-padding-y));
margin-left: auto;
}
}
// Title text within header
.modal-title {
margin-bottom: 0;
line-height: var(--#{$prefix}modal-title-line-height);
}
// Modal body
// Where all modal content resides (sibling of .modal-header and .modal-footer)
.modal-body {
position: relative;
// Enable `flex-grow: 1` so that the body take up as much space as possible
// when there should be a fixed height on `.modal-dialog`.
flex: 1 1 auto;
padding: var(--#{$prefix}modal-padding);
}
// Footer (for actions)
.modal-footer {
display: flex;
flex-shrink: 0;
flex-wrap: wrap;
align-items: center; // vertically center
justify-content: flex-end; // Right align buttons with flex property because text-align doesn't work on flex items
padding: calc(var(--#{$prefix}modal-padding) - var(--#{$prefix}modal-footer-gap) * .5);
background-color: var(--#{$prefix}modal-footer-bg);
border-top: var(--#{$prefix}modal-footer-border-width) solid var(--#{$prefix}modal-footer-border-color);
@include border-bottom-radius(var(--#{$prefix}modal-inner-border-radius));
// Place margin between footer elements
// This solution is far from ideal because of the universal selector usage,
// but is needed to fix https://github.com/twbs/bootstrap/issues/24800
> * {
margin: calc(var(--#{$prefix}modal-footer-gap) * .5); // Todo in v6: replace with gap on parent class
}
}
// Scale up the modal
@include media-breakpoint-up(sm) {
.modal {
--#{$prefix}modal-margin: #{$modal-dialog-margin-y-sm-up};
--#{$prefix}modal-box-shadow: #{$modal-content-box-shadow-sm-up};
}
// Automatically set modal's width for larger viewports
.modal-dialog {
max-width: var(--#{$prefix}modal-width);
margin-right: auto;
margin-left: auto;
}
.modal-sm {
--#{$prefix}modal-width: #{$modal-sm};
}
}
@include media-breakpoint-up(lg) {
.modal-lg,
.modal-xl {
--#{$prefix}modal-width: #{$modal-lg};
}
}
@include media-breakpoint-up(xl) {
.modal-xl {
--#{$prefix}modal-width: #{$modal-xl};
}
}
// scss-docs-start modal-fullscreen-loop
@each $breakpoint in map.keys($grid-breakpoints) {
$infix: breakpoint-infix($breakpoint, $grid-breakpoints);
$postfix: if(sass($infix != ""): $infix + "-down"; else: "");
@include media-breakpoint-down($breakpoint) {
.modal-fullscreen#{$postfix} {
width: 100vw;
max-width: none;
height: 100%;
margin: 0;
.modal-content {
height: 100%;
border: 0;
@include border-radius(0);
}
.modal-header,
.modal-footer {
@include border-radius(0);
}
.modal-body {
overflow-y: auto;
}
}
}
}
// scss-docs-end modal-fullscreen-loop

View File

@@ -0,0 +1,197 @@
// Base class
//
// Kickstart any navigation component with a set of style resets. Works with
// `<nav>`s, `<ul>`s or `<ol>`s.
.nav {
// scss-docs-start nav-css-vars
--#{$prefix}nav-link-padding-x: #{$nav-link-padding-x};
--#{$prefix}nav-link-padding-y: #{$nav-link-padding-y};
@include rfs($nav-link-font-size, --#{$prefix}nav-link-font-size);
--#{$prefix}nav-link-font-weight: #{$nav-link-font-weight};
--#{$prefix}nav-link-color: #{$nav-link-color};
--#{$prefix}nav-link-hover-color: #{$nav-link-hover-color};
--#{$prefix}nav-link-disabled-color: #{$nav-link-disabled-color};
// scss-docs-end nav-css-vars
display: flex;
flex-wrap: wrap;
padding-left: 0;
margin-bottom: 0;
list-style: none;
}
.nav-link {
display: block;
padding: var(--#{$prefix}nav-link-padding-y) var(--#{$prefix}nav-link-padding-x);
@include font-size(var(--#{$prefix}nav-link-font-size));
font-weight: var(--#{$prefix}nav-link-font-weight);
color: var(--#{$prefix}nav-link-color);
text-decoration: if(sass($link-decoration == none): null; else: none);
background: none;
border: 0;
@include transition($nav-link-transition);
&:hover,
&:focus {
color: var(--#{$prefix}nav-link-hover-color);
text-decoration: if(sass($link-hover-decoration == underline): none; else: null);
}
&:focus-visible {
outline: 0;
box-shadow: $nav-link-focus-box-shadow;
}
// Disabled state lightens text
&.disabled,
&:disabled {
color: var(--#{$prefix}nav-link-disabled-color);
pointer-events: none;
cursor: default;
}
}
//
// Tabs
//
.nav-tabs {
// scss-docs-start nav-tabs-css-vars
--#{$prefix}nav-tabs-border-width: #{$nav-tabs-border-width};
--#{$prefix}nav-tabs-border-color: #{$nav-tabs-border-color};
--#{$prefix}nav-tabs-border-radius: #{$nav-tabs-border-radius};
--#{$prefix}nav-tabs-link-hover-border-color: #{$nav-tabs-link-hover-border-color};
--#{$prefix}nav-tabs-link-active-color: #{$nav-tabs-link-active-color};
--#{$prefix}nav-tabs-link-active-bg: #{$nav-tabs-link-active-bg};
--#{$prefix}nav-tabs-link-active-border-color: #{$nav-tabs-link-active-border-color};
// scss-docs-end nav-tabs-css-vars
border-bottom: var(--#{$prefix}nav-tabs-border-width) solid var(--#{$prefix}nav-tabs-border-color);
.nav-link {
margin-bottom: calc(-1 * var(--#{$prefix}nav-tabs-border-width)); // stylelint-disable-line function-disallowed-list
border: var(--#{$prefix}nav-tabs-border-width) solid transparent;
@include border-top-radius(var(--#{$prefix}nav-tabs-border-radius));
&:hover,
&:focus {
// Prevents active .nav-link tab overlapping focus outline of previous/next .nav-link
isolation: isolate;
border-color: var(--#{$prefix}nav-tabs-link-hover-border-color);
}
}
.nav-link.active,
.nav-item.show .nav-link {
color: var(--#{$prefix}nav-tabs-link-active-color);
background-color: var(--#{$prefix}nav-tabs-link-active-bg);
border-color: var(--#{$prefix}nav-tabs-link-active-border-color);
}
.dropdown-menu {
// Make dropdown border overlap tab border
margin-top: calc(-1 * var(--#{$prefix}nav-tabs-border-width)); // stylelint-disable-line function-disallowed-list
// Remove the top rounded corners here since there is a hard edge above the menu
@include border-top-radius(0);
}
}
//
// Pills
//
.nav-pills {
// scss-docs-start nav-pills-css-vars
--#{$prefix}nav-pills-border-radius: #{$nav-pills-border-radius};
--#{$prefix}nav-pills-link-active-color: #{$nav-pills-link-active-color};
--#{$prefix}nav-pills-link-active-bg: #{$nav-pills-link-active-bg};
// scss-docs-end nav-pills-css-vars
.nav-link {
@include border-radius(var(--#{$prefix}nav-pills-border-radius));
}
.nav-link.active,
.show > .nav-link {
color: var(--#{$prefix}nav-pills-link-active-color);
@include gradient-bg(var(--#{$prefix}nav-pills-link-active-bg));
}
}
//
// Underline
//
.nav-underline {
// scss-docs-start nav-underline-css-vars
--#{$prefix}nav-underline-gap: #{$nav-underline-gap};
--#{$prefix}nav-underline-border-width: #{$nav-underline-border-width};
--#{$prefix}nav-underline-link-active-color: #{$nav-underline-link-active-color};
// scss-docs-end nav-underline-css-vars
gap: var(--#{$prefix}nav-underline-gap);
.nav-link {
padding-right: 0;
padding-left: 0;
border-bottom: var(--#{$prefix}nav-underline-border-width) solid transparent;
&:hover,
&:focus {
border-bottom-color: currentcolor;
}
}
.nav-link.active,
.show > .nav-link {
font-weight: $font-weight-bold;
color: var(--#{$prefix}nav-underline-link-active-color);
border-bottom-color: currentcolor;
}
}
//
// Justified variants
//
.nav-fill {
> .nav-link,
.nav-item {
flex: 1 1 auto;
text-align: center;
}
}
.nav-justified {
> .nav-link,
.nav-item {
flex-grow: 1;
flex-basis: 0;
text-align: center;
}
}
.nav-fill,
.nav-justified {
.nav-item .nav-link {
width: 100%; // Make sure button will grow
}
}
// Tabbable tabs
//
// Hide tabbable panes to start, show them when `.active`
.tab-content {
> .tab-pane {
display: none;
}
> .active {
display: block;
}
}

View File

@@ -0,0 +1,292 @@
@use "sass:map";
// Navbar
//
// Provide a static navbar from which we expand to create full-width, fixed, and
// other navbar variations.
.navbar {
// scss-docs-start navbar-css-vars
--#{$prefix}navbar-padding-x: #{if(sass($navbar-padding-x == null): 0; else: $navbar-padding-x)};
--#{$prefix}navbar-padding-y: #{$navbar-padding-y};
--#{$prefix}navbar-color: #{$navbar-light-color};
--#{$prefix}navbar-hover-color: #{$navbar-light-hover-color};
--#{$prefix}navbar-disabled-color: #{$navbar-light-disabled-color};
--#{$prefix}navbar-active-color: #{$navbar-light-active-color};
--#{$prefix}navbar-brand-padding-y: #{$navbar-brand-padding-y};
--#{$prefix}navbar-brand-margin-end: #{$navbar-brand-margin-end};
--#{$prefix}navbar-brand-font-size: #{$navbar-brand-font-size};
--#{$prefix}navbar-brand-color: #{$navbar-light-brand-color};
--#{$prefix}navbar-brand-hover-color: #{$navbar-light-brand-hover-color};
--#{$prefix}navbar-nav-link-padding-x: #{$navbar-nav-link-padding-x};
--#{$prefix}navbar-toggler-padding-y: #{$navbar-toggler-padding-y};
--#{$prefix}navbar-toggler-padding-x: #{$navbar-toggler-padding-x};
--#{$prefix}navbar-toggler-font-size: #{$navbar-toggler-font-size};
--#{$prefix}navbar-toggler-icon-bg: #{escape-svg($navbar-light-toggler-icon-bg)};
--#{$prefix}navbar-toggler-border-color: #{$navbar-light-toggler-border-color};
--#{$prefix}navbar-toggler-border-radius: #{$navbar-toggler-border-radius};
--#{$prefix}navbar-toggler-focus-width: #{$navbar-toggler-focus-width};
--#{$prefix}navbar-toggler-transition: #{$navbar-toggler-transition};
// scss-docs-end navbar-css-vars
position: relative;
display: flex;
flex-wrap: wrap; // allow us to do the line break for collapsing content
align-items: center;
justify-content: space-between; // space out brand from logo
padding: var(--#{$prefix}navbar-padding-y) var(--#{$prefix}navbar-padding-x);
@include gradient-bg();
// Because flex properties aren't inherited, we need to redeclare these first
// few properties so that content nested within behave properly.
// The `flex-wrap` property is inherited to simplify the expanded navbars
%container-flex-properties {
display: flex;
flex-wrap: inherit;
align-items: center;
justify-content: space-between;
}
> .container,
> .container-fluid {
@extend %container-flex-properties;
}
@each $breakpoint, $container-max-width in $container-max-widths {
> .container#{breakpoint-infix($breakpoint, $container-max-widths)} {
@extend %container-flex-properties;
}
}
}
// Navbar brand
//
// Used for brand, project, or site names.
.navbar-brand {
padding-top: var(--#{$prefix}navbar-brand-padding-y);
padding-bottom: var(--#{$prefix}navbar-brand-padding-y);
margin-right: var(--#{$prefix}navbar-brand-margin-end);
@include font-size(var(--#{$prefix}navbar-brand-font-size));
color: var(--#{$prefix}navbar-brand-color);
text-decoration: if(sass($link-decoration == none): null; else: none);
white-space: nowrap;
&:hover,
&:focus {
color: var(--#{$prefix}navbar-brand-hover-color);
text-decoration: if(sass($link-hover-decoration == underline): none; else: null);
}
}
// Navbar nav
//
// Custom navbar navigation (doesn't require `.nav`, but does make use of `.nav-link`).
.navbar-nav {
// scss-docs-start navbar-nav-css-vars
--#{$prefix}nav-link-padding-x: 0;
--#{$prefix}nav-link-padding-y: #{$nav-link-padding-y};
@include rfs($nav-link-font-size, --#{$prefix}nav-link-font-size);
--#{$prefix}nav-link-font-weight: #{$nav-link-font-weight};
--#{$prefix}nav-link-color: var(--#{$prefix}navbar-color);
--#{$prefix}nav-link-hover-color: var(--#{$prefix}navbar-hover-color);
--#{$prefix}nav-link-disabled-color: var(--#{$prefix}navbar-disabled-color);
// scss-docs-end navbar-nav-css-vars
display: flex;
flex-direction: column; // cannot use `inherit` to get the `.navbar`s value
padding-left: 0;
margin-bottom: 0;
list-style: none;
.nav-link {
&.active,
&.show {
color: var(--#{$prefix}navbar-active-color);
}
}
.dropdown-menu {
position: static;
}
}
// Navbar text
//
//
.navbar-text {
padding-top: $nav-link-padding-y;
padding-bottom: $nav-link-padding-y;
color: var(--#{$prefix}navbar-color);
a,
a:hover,
a:focus {
color: var(--#{$prefix}navbar-active-color);
}
}
// Responsive navbar
//
// Custom styles for responsive collapsing and toggling of navbar contents.
// Powered by the collapse Bootstrap JavaScript plugin.
// When collapsed, prevent the toggleable navbar contents from appearing in
// the default flexbox row orientation. Requires the use of `flex-wrap: wrap`
// on the `.navbar` parent.
.navbar-collapse {
flex-grow: 1;
flex-basis: 100%;
// For always expanded or extra full navbars, ensure content aligns itself
// properly vertically. Can be easily overridden with flex utilities.
align-items: center;
}
// Button for toggling the navbar when in its collapsed state
.navbar-toggler {
padding: var(--#{$prefix}navbar-toggler-padding-y) var(--#{$prefix}navbar-toggler-padding-x);
@include font-size(var(--#{$prefix}navbar-toggler-font-size));
line-height: 1;
color: var(--#{$prefix}navbar-color);
background-color: transparent; // remove default button style
border: var(--#{$prefix}border-width) solid var(--#{$prefix}navbar-toggler-border-color); // remove default button style
@include border-radius(var(--#{$prefix}navbar-toggler-border-radius));
@include transition(var(--#{$prefix}navbar-toggler-transition));
&:hover {
text-decoration: none;
}
&:focus {
text-decoration: none;
outline: 0;
box-shadow: 0 0 0 var(--#{$prefix}navbar-toggler-focus-width);
}
}
// Keep as a separate element so folks can easily override it with another icon
// or image file as needed.
.navbar-toggler-icon {
display: inline-block;
width: 1.5em;
height: 1.5em;
vertical-align: middle;
background-image: var(--#{$prefix}navbar-toggler-icon-bg);
background-repeat: no-repeat;
background-position: center;
background-size: 100%;
}
.navbar-nav-scroll {
max-height: var(--#{$prefix}scroll-height, 75vh);
overflow-y: auto;
}
// scss-docs-start navbar-expand-loop
// Generate series of `.navbar-expand-*` responsive classes for configuring
// where your navbar collapses.
.navbar-expand {
@each $breakpoint in map.keys($grid-breakpoints) {
$next: breakpoint-next($breakpoint, $grid-breakpoints);
$infix: breakpoint-infix($next, $grid-breakpoints);
// stylelint-disable-next-line scss/selector-no-union-class-name
&#{$infix} {
@include media-breakpoint-up($next) {
flex-wrap: nowrap;
justify-content: flex-start;
.navbar-nav {
flex-direction: row;
.dropdown-menu {
position: absolute;
}
.nav-link {
padding-right: var(--#{$prefix}navbar-nav-link-padding-x);
padding-left: var(--#{$prefix}navbar-nav-link-padding-x);
}
}
.navbar-nav-scroll {
overflow: visible;
}
.navbar-collapse {
display: flex !important; // stylelint-disable-line declaration-no-important
flex-basis: auto;
}
.navbar-toggler {
display: none;
}
.offcanvas {
// stylelint-disable declaration-no-important
position: static;
z-index: auto;
flex-grow: 1;
width: auto !important;
height: auto !important;
visibility: visible !important;
background-color: transparent !important;
border: 0 !important;
transform: none !important;
@include box-shadow(none);
@include transition(none);
// stylelint-enable declaration-no-important
.offcanvas-header {
display: none;
}
.offcanvas-body {
display: flex;
flex-grow: 0;
padding: 0;
overflow-y: visible;
}
}
}
}
}
}
// scss-docs-end navbar-expand-loop
// Navbar themes
//
// Styles for switching between navbars with light or dark background.
.navbar-light {
@include deprecate("`.navbar-light`", "v5.2.0", "v6.0.0", true);
}
.navbar-dark,
.navbar[data-bs-theme="dark"],
.navbar[data-theme="dark"] {
// scss-docs-start navbar-dark-css-vars
--#{$prefix}navbar-color: #{$navbar-dark-color};
--#{$prefix}navbar-hover-color: #{$navbar-dark-hover-color};
--#{$prefix}navbar-disabled-color: #{$navbar-dark-disabled-color};
--#{$prefix}navbar-active-color: #{$navbar-dark-active-color};
--#{$prefix}navbar-brand-color: #{$navbar-dark-brand-color};
--#{$prefix}navbar-brand-hover-color: #{$navbar-dark-brand-hover-color};
--#{$prefix}navbar-toggler-border-color: #{$navbar-dark-toggler-border-color};
--#{$prefix}navbar-toggler-icon-bg: #{escape-svg($navbar-dark-toggler-icon-bg)};
// scss-docs-end navbar-dark-css-vars
}
@if $enable-dark-mode {
@include color-mode(dark) {
.navbar-toggler-icon {
--#{$prefix}navbar-toggler-icon-bg: #{escape-svg($navbar-dark-toggler-icon-bg)};
}
}
}

View File

@@ -0,0 +1,149 @@
// stylelint-disable function-disallowed-list
@use "sass:map";
%offcanvas-css-vars {
// scss-docs-start offcanvas-css-vars
--#{$prefix}offcanvas-zindex: #{$zindex-offcanvas};
--#{$prefix}offcanvas-width: #{$offcanvas-horizontal-width};
--#{$prefix}offcanvas-height: #{$offcanvas-vertical-height};
--#{$prefix}offcanvas-padding-x: #{$offcanvas-padding-x};
--#{$prefix}offcanvas-padding-y: #{$offcanvas-padding-y};
--#{$prefix}offcanvas-color: #{$offcanvas-color};
--#{$prefix}offcanvas-bg: #{$offcanvas-bg-color};
--#{$prefix}offcanvas-border-width: #{$offcanvas-border-width};
--#{$prefix}offcanvas-border-color: #{$offcanvas-border-color};
--#{$prefix}offcanvas-box-shadow: #{$offcanvas-box-shadow};
--#{$prefix}offcanvas-transition: #{transform $offcanvas-transition-duration ease-in-out};
--#{$prefix}offcanvas-title-line-height: #{$offcanvas-title-line-height};
// scss-docs-end offcanvas-css-vars
}
@each $breakpoint in map.keys($grid-breakpoints) {
$next: breakpoint-next($breakpoint, $grid-breakpoints);
$infix: breakpoint-infix($next, $grid-breakpoints);
.offcanvas#{$infix} {
@extend %offcanvas-css-vars;
}
}
@each $breakpoint in map.keys($grid-breakpoints) {
$next: breakpoint-next($breakpoint, $grid-breakpoints);
$infix: breakpoint-infix($next, $grid-breakpoints);
.offcanvas#{$infix} {
@include media-breakpoint-down($next) {
position: fixed;
bottom: 0;
z-index: var(--#{$prefix}offcanvas-zindex);
display: flex;
flex-direction: column;
max-width: 100%;
color: var(--#{$prefix}offcanvas-color);
visibility: hidden;
background-color: var(--#{$prefix}offcanvas-bg);
background-clip: padding-box;
outline: 0;
@include box-shadow(var(--#{$prefix}offcanvas-box-shadow));
@include transition(var(--#{$prefix}offcanvas-transition));
&.offcanvas-start {
top: 0;
left: 0;
width: var(--#{$prefix}offcanvas-width);
border-right: var(--#{$prefix}offcanvas-border-width) solid var(--#{$prefix}offcanvas-border-color);
transform: translateX(-100%);
}
&.offcanvas-end {
top: 0;
right: 0;
width: var(--#{$prefix}offcanvas-width);
border-left: var(--#{$prefix}offcanvas-border-width) solid var(--#{$prefix}offcanvas-border-color);
transform: translateX(100%);
}
&.offcanvas-top {
top: 0;
right: 0;
left: 0;
height: var(--#{$prefix}offcanvas-height);
max-height: 100%;
border-bottom: var(--#{$prefix}offcanvas-border-width) solid var(--#{$prefix}offcanvas-border-color);
transform: translateY(-100%);
}
&.offcanvas-bottom {
right: 0;
left: 0;
height: var(--#{$prefix}offcanvas-height);
max-height: 100%;
border-top: var(--#{$prefix}offcanvas-border-width) solid var(--#{$prefix}offcanvas-border-color);
transform: translateY(100%);
}
&.showing,
&.show:not(.hiding) {
transform: none;
}
&.showing,
&.hiding,
&.show {
visibility: visible;
}
}
@if not ($infix == "") {
@include media-breakpoint-up($next) {
--#{$prefix}offcanvas-height: auto;
--#{$prefix}offcanvas-border-width: 0;
background-color: transparent !important; // stylelint-disable-line declaration-no-important
.offcanvas-header {
display: none;
}
.offcanvas-body {
display: flex;
flex-grow: 0;
padding: 0;
overflow-y: visible;
// Reset `background-color` in case `.bg-*` classes are used in offcanvas
background-color: transparent !important; // stylelint-disable-line declaration-no-important
}
}
}
}
}
.offcanvas-backdrop {
@include overlay-backdrop($zindex-offcanvas-backdrop, $offcanvas-backdrop-bg, $offcanvas-backdrop-opacity);
}
.offcanvas-header {
display: flex;
align-items: center;
padding: var(--#{$prefix}offcanvas-padding-y) var(--#{$prefix}offcanvas-padding-x);
.btn-close {
padding: calc(var(--#{$prefix}offcanvas-padding-y) * .5) calc(var(--#{$prefix}offcanvas-padding-x) * .5);
// Split properties to avoid invalid calc() function if value is 0
margin-top: calc(-.5 * var(--#{$prefix}offcanvas-padding-y));
margin-right: calc(-.5 * var(--#{$prefix}offcanvas-padding-x));
margin-bottom: calc(-.5 * var(--#{$prefix}offcanvas-padding-y));
margin-left: auto;
}
}
.offcanvas-title {
margin-bottom: 0;
line-height: var(--#{$prefix}offcanvas-title-line-height);
}
.offcanvas-body {
flex-grow: 1;
padding: var(--#{$prefix}offcanvas-padding-y) var(--#{$prefix}offcanvas-padding-x);
overflow-y: auto;
}

View File

@@ -0,0 +1,109 @@
.pagination {
// scss-docs-start pagination-css-vars
--#{$prefix}pagination-padding-x: #{$pagination-padding-x};
--#{$prefix}pagination-padding-y: #{$pagination-padding-y};
@include rfs($pagination-font-size, --#{$prefix}pagination-font-size);
--#{$prefix}pagination-color: #{$pagination-color};
--#{$prefix}pagination-bg: #{$pagination-bg};
--#{$prefix}pagination-border-width: #{$pagination-border-width};
--#{$prefix}pagination-border-color: #{$pagination-border-color};
--#{$prefix}pagination-border-radius: #{$pagination-border-radius};
--#{$prefix}pagination-hover-color: #{$pagination-hover-color};
--#{$prefix}pagination-hover-bg: #{$pagination-hover-bg};
--#{$prefix}pagination-hover-border-color: #{$pagination-hover-border-color};
--#{$prefix}pagination-focus-color: #{$pagination-focus-color};
--#{$prefix}pagination-focus-bg: #{$pagination-focus-bg};
--#{$prefix}pagination-focus-box-shadow: #{$pagination-focus-box-shadow};
--#{$prefix}pagination-active-color: #{$pagination-active-color};
--#{$prefix}pagination-active-bg: #{$pagination-active-bg};
--#{$prefix}pagination-active-border-color: #{$pagination-active-border-color};
--#{$prefix}pagination-disabled-color: #{$pagination-disabled-color};
--#{$prefix}pagination-disabled-bg: #{$pagination-disabled-bg};
--#{$prefix}pagination-disabled-border-color: #{$pagination-disabled-border-color};
// scss-docs-end pagination-css-vars
display: flex;
@include list-unstyled();
}
.page-link {
position: relative;
display: block;
padding: var(--#{$prefix}pagination-padding-y) var(--#{$prefix}pagination-padding-x);
@include font-size(var(--#{$prefix}pagination-font-size));
color: var(--#{$prefix}pagination-color);
text-decoration: if(sass($link-decoration == none): null; else: none);
background-color: var(--#{$prefix}pagination-bg);
border: var(--#{$prefix}pagination-border-width) solid var(--#{$prefix}pagination-border-color);
@include transition($pagination-transition);
&:hover {
z-index: 2;
color: var(--#{$prefix}pagination-hover-color);
text-decoration: if(sass($link-hover-decoration == underline): none; else: null);
background-color: var(--#{$prefix}pagination-hover-bg);
border-color: var(--#{$prefix}pagination-hover-border-color);
}
&:focus {
z-index: 3;
color: var(--#{$prefix}pagination-focus-color);
background-color: var(--#{$prefix}pagination-focus-bg);
outline: $pagination-focus-outline;
box-shadow: var(--#{$prefix}pagination-focus-box-shadow);
}
&.active,
.active > & {
z-index: 3;
color: var(--#{$prefix}pagination-active-color);
@include gradient-bg(var(--#{$prefix}pagination-active-bg));
border-color: var(--#{$prefix}pagination-active-border-color);
}
&.disabled,
.disabled > & {
color: var(--#{$prefix}pagination-disabled-color);
pointer-events: none;
background-color: var(--#{$prefix}pagination-disabled-bg);
border-color: var(--#{$prefix}pagination-disabled-border-color);
}
}
.page-item {
&:not(:first-child) .page-link {
margin-left: $pagination-margin-start;
}
@if $pagination-margin-start == calc(-1 * #{$pagination-border-width}) {
&:first-child {
.page-link {
@include border-start-radius(var(--#{$prefix}pagination-border-radius));
}
}
&:last-child {
.page-link {
@include border-end-radius(var(--#{$prefix}pagination-border-radius));
}
}
} @else {
// Add border-radius to all pageLinks in case they have left margin
.page-link {
@include border-radius(var(--#{$prefix}pagination-border-radius));
}
}
}
//
// Sizing
//
.pagination-lg {
@include pagination-size($pagination-padding-y-lg, $pagination-padding-x-lg, $font-size-lg, $pagination-border-radius-lg);
}
.pagination-sm {
@include pagination-size($pagination-padding-y-sm, $pagination-padding-x-sm, $font-size-sm, $pagination-border-radius-sm);
}

Some files were not shown because too many files have changed in this diff Show More