Compare commits

...

13 Commits

Author SHA1 Message Date
codecalm
224de12414 refactor: rename Clipboard component to Copy and update related imports and tests for consistency 2026-01-11 15:28:34 +01:00
codecalm
da69e147fc feat: introduce new theme customization options and improve user interface responsiveness 2026-01-11 01:12:20 +01:00
codecalm
087794dc49 feat: implement Tabler API for theme management with global access and enhanced functionality 2026-01-11 01:07:15 +01:00
codecalm
d0e92a3fe0 refactor: simplify theme initialization by removing legacy configuration and importing theme directly 2026-01-11 00:56:02 +01:00
codecalm
1d4c1fa016 feat: add modal, collapse, and offcanvas components to Tabler 2026-01-11 00:52:41 +01:00
codecalm
276fe61996 refactor: remove deprecated bootstrap compatibility file and update imports to use 'bootstrap' directly 2026-01-11 00:49:42 +01:00
codecalm
4abc069959 feat: enhance testing setup with Vitest integration and add test coverage configuration 2026-01-11 00:40:38 +01:00
codecalm
01b8208227 feat: add Clipboard component and update documentation for clipboard functionality 2026-01-10 23:47:50 +01:00
codecalm
d3a358fec9 feat: add InputMask component with initialization, update, and disposal methods, and update documentation for usage 2026-01-10 23:38:14 +01:00
codecalm
4ef9dbde51 feat: implement Countup component with initialization, update, and disposal methods 2026-01-10 23:12:50 +01:00
codecalm
ebcfa18060 feat: add SwitchIcon component to Tabler with initialization and toggle functionality 2026-01-10 23:08:34 +01:00
codecalm
96168a826c feat: integrate autosize component into Tabler with enhanced functionality and documentation 2026-01-10 22:42:10 +01:00
codecalm
c3e6aa1bd3 refactor: update stylesheet linking logic in default.html 2026-01-10 21:30:34 +01:00
51 changed files with 4383 additions and 413 deletions

42
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,42 @@
name: Build
on:
pull_request: null
env:
NODE: 22
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Clone repository
uses: actions/checkout@v6
- name: Cache turbo build setup
uses: actions/cache@v5
with:
path: .turbo
key: ${{ runner.os }}-turbo-${{ github.sha }}
restore-keys: |
${{ runner.os }}-turbo-
- name: Install PNPM
uses: pnpm/action-setup@v4
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: "${{ env.NODE }}"
cache: 'pnpm'
- run: node --version
- name: Install pnpm dependencies
run: pnpm install
- name: Build
run: pnpm run build

View File

@@ -1,4 +1,4 @@
name: Test build
name: Test
on:
pull_request: null
@@ -38,5 +38,5 @@ jobs:
- name: Install pnpm dependencies
run: pnpm install
- name: Build
run: pnpm run build
- name: Run tests
run: pnpm run test

7
.gitignore vendored
View File

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

View File

@@ -1,10 +1,107 @@
// Autosize plugin
const autosizeElements: NodeListOf<HTMLElement> = document.querySelectorAll<HTMLElement>('[data-bs-toggle="autosize"]')
import autosize from 'autosize'
if (autosizeElements.length) {
autosizeElements.forEach(function (element: HTMLElement) {
if (window.autosize) {
window.autosize(element)
/**
* --------------------------------------------------------------------------
* Tabler autosize.js
* --------------------------------------------------------------------------
*/
/**
* Constants
*/
const NAME = 'autosize'
const DATA_KEY = `tblr.${NAME}`
const SELECTOR_DATA_AUTOSIZE = '[data-bs-autosize]'
/**
* Class definition
*/
class Autosize {
private element: HTMLElement | HTMLTextAreaElement
private initialized: boolean = false
constructor(element: HTMLElement | HTMLTextAreaElement) {
this.element = element
}
// Getters
static get NAME() {
return NAME
}
static get DATA_KEY() {
return DATA_KEY
}
// Public
/**
* Initialize autosize on the element
*/
init(): void {
if (this.initialized) {
return
}
})
autosize(this.element)
this.initialized = true
}
/**
* Update autosize (useful when content changes programmatically)
*/
update(): void {
if (!this.initialized) {
return
}
autosize.update(this.element)
}
/**
* Destroy autosize instance
*/
dispose(): void {
if (!this.initialized) {
return
}
autosize.destroy(this.element)
this.initialized = false
}
// Static
/**
* Get instance from element
*/
static getInstance(element: HTMLElement | HTMLTextAreaElement): Autosize | null {
return elementMap.get(element) || null
}
/**
* Get or create instance
*/
static getOrCreateInstance(element: HTMLElement | HTMLTextAreaElement): Autosize {
return this.getInstance(element) || new this(element)
}
}
/**
* Instance storage
*/
const elementMap = new WeakMap<HTMLElement | HTMLTextAreaElement, Autosize>()
/**
* Data API implementation
*/
const autosizeTriggerList: HTMLElement[] = [].slice.call(document.querySelectorAll<HTMLElement>(SELECTOR_DATA_AUTOSIZE))
autosizeTriggerList.map(function (autosizeTriggerEl: HTMLElement) {
const instance = Autosize.getOrCreateInstance(autosizeTriggerEl)
elementMap.set(autosizeTriggerEl, instance)
instance.init()
return instance
})
export default Autosize

View File

@@ -1,7 +0,0 @@
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'
// Re-export everything as namespace for backward compatibility
export * as bootstrap from 'bootstrap'

2
core/js/src/clipboard.ts Normal file
View File

@@ -0,0 +1,2 @@
// Backward compatibility alias (old file name).
export { default } from './copy'

26
core/js/src/collapse.ts Normal file
View File

@@ -0,0 +1,26 @@
import { Collapse } from 'bootstrap'
/*
Core collapse
*/
const collapseTriggerList: HTMLElement[] = [].slice.call(document.querySelectorAll<HTMLElement>('[data-bs-toggle="collapse"]'))
collapseTriggerList.map(function (collapseTriggerEl: HTMLElement) {
const target = collapseTriggerEl.getAttribute('data-bs-target') || collapseTriggerEl.getAttribute('href')
if (target === null) {
return
}
const collapseEl = document.querySelector<HTMLElement>(target)
if (collapseEl === null) {
return
}
const collapse = new Collapse(collapseEl, {
toggle: false,
})
collapseTriggerEl.addEventListener('click', (e) => {
e.preventDefault()
collapse.toggle()
})
})

334
core/js/src/copy.ts Normal file
View File

@@ -0,0 +1,334 @@
/**
* --------------------------------------------------------------------------
* Tabler copy.js
* --------------------------------------------------------------------------
*/
import { Tooltip } from 'bootstrap'
/**
* Constants
*/
const NAME = 'copy'
const DATA_KEY = `bs.${NAME}`
type CopyConfig = {
timeout: number
reset: boolean
extend: boolean
recopy: boolean
feedback: 'swap' | 'tooltip'
tooltip: string
tooltipPlacement: string
}
const Default: CopyConfig = {
timeout: 1500,
reset: true,
extend: true,
recopy: true,
feedback: 'swap',
tooltip: 'Copied!',
tooltipPlacement: 'top',
}
const Selector = {
toggle: '[data-bs-toggle="copy"]',
default: '[data-bs-copy-default]',
success: '[data-bs-copy-success]',
} as const
const Event = {
COPY: `copy.bs.${NAME}`,
COPIED: `copied.bs.${NAME}`,
COPYFAIL: `copyfail.bs.${NAME}`,
RESET: `reset.bs.${NAME}`,
} as const
/**
* Class definition
*/
class Copy {
static NAME = NAME
static DATA_KEY = DATA_KEY
static Default = Default
static Selector = Selector
static Event = Event
private _element: HTMLElement | null
private _timer: number | null = null
private _isCopied: boolean = false
private _config: CopyConfig
private _tooltipInstance: any = null
constructor(element: HTMLElement, config: Partial<CopyConfig> = {}) {
this._element = element
this._config = { ...Default, ...this._getConfig(), ...config }
}
static getInstance(element?: HTMLElement | null): Copy | null {
return (element as any)?.[DATA_KEY] ?? null
}
static getOrCreateInstance(element?: HTMLElement | null, config?: Partial<CopyConfig>): Copy | null {
if (!element) return null
;(element as any)[DATA_KEY] ??= new Copy(element, config)
return (element as any)[DATA_KEY]
}
dispose(): void {
if (this._timer) clearTimeout(this._timer)
this._timer = null
this._disposeTooltip()
if (this._element) {
delete (this._element as any)[DATA_KEY]
}
this._element = null
}
/**
* Public API: manual reset
*/
reset(): void {
if (!this._element) return
if (!this._isCopied) return
if (this._timer) clearTimeout(this._timer)
this._timer = null
this._isCopied = false
this._applyFeedback(false)
this._trigger(Event.RESET, { manual: true })
}
async copy(): Promise<boolean> {
if (!this._element) return false
// Already "Copied" and recopy disabled — just extend timer or noop
if (this._isCopied && !this._config.recopy) {
if (this._config.reset && this._config.extend) this._armResetTimer()
return true
}
const text = this._getText()
const target = this._getTarget()
const before = this._trigger(Event.COPY, { text, target })
if (before.defaultPrevented) return false
if (text == null) {
this._trigger(Event.COPYFAIL, { text: null, target, reason: 'no-text' })
return false
}
const ok = await this._writeText(text)
if (!ok) {
this._trigger(Event.COPYFAIL, { text, target, reason: 'clipboard-failed' })
return false
}
this._isCopied = true
this._applyFeedback(true)
this._trigger(Event.COPIED, { text, target })
if (this._config.reset) this._armResetTimer()
return true
}
private _getConfig(): Partial<CopyConfig> {
if (!this._element) return {}
const d = this._element.dataset
const num = (v: string | undefined) => {
if (v == null || v === '') return undefined
const n = Number(v)
return Number.isFinite(n) ? n : undefined
}
const bool = (v: string | undefined) => (v != null ? v !== 'false' : undefined)
const str = (v: string | undefined) => (v != null && v !== '' ? v : undefined)
const config: Partial<CopyConfig> = {}
const timeout = num(d.bsCopyTimeout)
const reset = bool(d.bsCopyReset)
const extend = bool(d.bsCopyExtend)
const recopy = bool(d.bsCopyRecopy)
const feedback = str(d.bsCopyFeedback)
const tooltip = str(d.bsCopyTooltip)
const tooltipPlacement = str(d.bsCopyTooltipPlacement)
if (timeout !== undefined) config.timeout = timeout
if (reset !== undefined) config.reset = reset
if (extend !== undefined) config.extend = extend
if (recopy !== undefined) config.recopy = recopy
if (feedback === 'swap' || feedback === 'tooltip') config.feedback = feedback
if (tooltip !== undefined) config.tooltip = tooltip
if (tooltipPlacement !== undefined) config.tooltipPlacement = tooltipPlacement
return config
}
private _getTarget(): Element | null {
if (!this._element) return null
const selector = this._element.dataset.bsCopyTarget
return selector ? document.querySelector(selector) : null
}
private _getText(): string | null {
if (!this._element) return null
const d = this._element.dataset
if (d.bsCopyText) return d.bsCopyText
const target = this._getTarget()
if (!target) return null
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) return target.value
return target.textContent?.trim() ?? ''
}
private async _writeText(text: string): Promise<boolean> {
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text)
return true
} catch {
// ignore and fallback
}
}
try {
const ta = document.createElement('textarea')
ta.value = text
ta.setAttribute('readonly', '')
ta.style.position = 'fixed'
ta.style.left = '-9999px'
document.body.appendChild(ta)
ta.select()
const ok = document.execCommand('copy')
document.body.removeChild(ta)
return ok
} catch {
return false
}
}
private _armResetTimer(): void {
if (!this._element) return
if (!this._config.extend && this._timer) return
if (this._timer) clearTimeout(this._timer)
this._timer = window.setTimeout(() => {
this._isCopied = false
this._applyFeedback(false)
this._trigger(Event.RESET, { manual: false })
}, this._config.timeout)
}
private _applyFeedback(isCopied: boolean): void {
if (!this._element) return
const mode = this._config.feedback
// Tooltip mode: use Bootstrap Tooltip (manual trigger)
if (mode === 'tooltip') {
if (isCopied) this._showTooltip()
else this._hideTooltip()
return
}
this._swapText(isCopied)
}
private _swapText(isCopied: boolean): void {
if (!this._element) return
const def = this._element.querySelector<HTMLElement>(Selector.default)
const success = this._element.querySelector<HTMLElement>(Selector.success)
if (def) {
def.hidden = isCopied
def.classList.toggle('d-none', isCopied)
}
if (success) {
success.hidden = !isCopied
success.classList.toggle('d-none', !isCopied)
}
this._element.classList.toggle('is-copied', isCopied)
if (isCopied) this._element.setAttribute('aria-label', 'Copied')
else this._element.removeAttribute('aria-label')
}
private _showTooltip(): void {
if (!this._element) return
if (!this._tooltipInstance) {
this._element.setAttribute('data-bs-title', this._config.tooltip)
this._tooltipInstance = Tooltip.getOrCreateInstance(this._element, {
trigger: 'manual',
placement: this._config.tooltipPlacement,
})
} else {
this._element.setAttribute('data-bs-title', this._config.tooltip)
// Bootstrap 5.3+ supports setContent(); use it when available
if (typeof this._tooltipInstance.setContent === 'function') {
this._tooltipInstance.setContent({ '.tooltip-inner': this._config.tooltip })
}
}
this._tooltipInstance.show()
}
private _hideTooltip(): void {
this._tooltipInstance?.hide()
}
private _disposeTooltip(): void {
if (this._tooltipInstance) {
this._tooltipInstance.dispose()
this._tooltipInstance = null
}
}
private _trigger(type: string, detail: unknown): CustomEvent {
if (!this._element) {
return new CustomEvent(type, { bubbles: true, cancelable: false, detail })
}
const cancelable = type === Event.COPY
const event = new CustomEvent(type, { bubbles: true, cancelable, detail })
this._element.dispatchEvent(event)
return event
}
}
/**
* Data API — click
*/
document.addEventListener('click', (e) => {
const target = e.target as Element | null
const trigger = target?.closest?.(Selector.toggle) as HTMLElement | null
if (!trigger) return
e.preventDefault()
Copy.getOrCreateInstance(trigger)?.copy()
})
/**
* Data API — Enter/Space
*/
document.addEventListener('keydown', (e) => {
const ke = e as KeyboardEvent
if (ke.key !== 'Enter' && ke.key !== ' ' && ke.key !== 'Spacebar') return
const target = ke.target as Element | null
const trigger = target?.closest?.(Selector.toggle) as HTMLElement | null
if (!trigger) return
ke.preventDefault()
Copy.getOrCreateInstance(trigger)?.copy()
})
export default Copy

View File

@@ -1,10 +1,56 @@
const countupElements: NodeListOf<HTMLElement> = document.querySelectorAll<HTMLElement>('[data-countup]')
import { CountUp } from 'countup.js'
if (countupElements.length) {
countupElements.forEach(function (element: HTMLElement) {
/**
* --------------------------------------------------------------------------
* Tabler countup.js
* --------------------------------------------------------------------------
*/
/**
* Constants
*/
const NAME = 'countup'
const DATA_KEY = `tblr.${NAME}`
const SELECTOR_DATA_COUNTUP = '[data-countup]'
/**
* Class definition
*/
class Countup {
private element: HTMLElement
private countUpInstance: CountUp | null = null
private initialized: boolean = false
private options: Record<string, any> = {}
constructor(element: HTMLElement) {
this.element = element
}
// Getters
static get NAME() {
return NAME
}
static get DATA_KEY() {
return DATA_KEY
}
// Public
/**
* Initialize countup on the element
*/
init(): void {
if (this.initialized) {
return
}
// Parse options from data attribute
let options: Record<string, any> = {}
try {
const dataOptions = element.getAttribute('data-countup') ? JSON.parse(element.getAttribute('data-countup')!) : {}
const dataOptions = this.element.getAttribute('data-countup') ? JSON.parse(this.element.getAttribute('data-countup')!) : {}
options = Object.assign(
{
enableScrollSpy: true,
@@ -15,13 +61,79 @@ if (countupElements.length) {
// ignore invalid JSON
}
const value = parseInt(element.innerHTML, 10)
this.options = options
if (window.countUp && window.countUp.CountUp) {
const countUp = new window.countUp.CountUp(element, value, options)
if (!countUp.error) {
countUp.start()
const value = parseInt(this.element.innerHTML, 10)
if (!isNaN(value)) {
this.countUpInstance = new CountUp(this.element, value, options)
if (!this.countUpInstance.error) {
this.countUpInstance.start()
this.initialized = true
}
}
})
}
/**
* Update countup (restart animation)
*/
update(): void {
if (!this.initialized || !this.countUpInstance) {
return
}
const value = parseInt(this.element.innerHTML, 10)
if (!isNaN(value)) {
this.countUpInstance = new CountUp(this.element, value, this.options)
if (!this.countUpInstance.error) {
this.countUpInstance.start()
}
}
}
/**
* Destroy countup instance
*/
dispose(): void {
if (!this.initialized) {
return
}
// CountUp doesn't have a destroy method, so we just reset state
this.countUpInstance = null
this.initialized = false
}
// Static
/**
* Get instance from element
*/
static getInstance(element: HTMLElement): Countup | null {
return elementMap.get(element) || null
}
/**
* Get or create instance
*/
static getOrCreateInstance(element: HTMLElement): Countup {
return this.getInstance(element) || new this(element)
}
}
/**
* Instance storage
*/
const elementMap = new WeakMap<HTMLElement, Countup>()
/**
* Data API implementation
*/
const countupTriggerList: HTMLElement[] = [].slice.call(document.querySelectorAll<HTMLElement>(SELECTOR_DATA_COUNTUP))
countupTriggerList.map(function (countupTriggerEl: HTMLElement) {
const instance = Countup.getOrCreateInstance(countupTriggerEl)
elementMap.set(countupTriggerEl, instance)
instance.init()
return instance
})
export default Countup

View File

@@ -1,4 +1,4 @@
import { Dropdown } from './bootstrap'
import { Dropdown } from 'bootstrap'
/*
Core dropdowns

View File

@@ -10,5 +10,20 @@ interface Window {
}
IMask?: new (element: HTMLElement, options: { mask: string; lazy?: boolean }) => any
Sortable?: new (element: HTMLElement, options?: any) => any
Tabler?: {
setTheme: (value: string) => void
setPrimary: (value: string) => void
setBase: (value: string) => void
setFont: (value: string) => void
setRadius: (value: string) => void
reset: () => void
getConfig: () => {
theme: string
'theme-base': string
'theme-font': string
'theme-primary': string
'theme-radius': string
}
}
}

View File

@@ -1,10 +1,153 @@
// Input mask plugin
import IMask, { type InputMask as IMaskInputMask } from 'imask'
const maskElementList: HTMLElement[] = [].slice.call(document.querySelectorAll<HTMLElement>('[data-mask]'))
maskElementList.map(function (maskEl: HTMLElement) {
window.IMask &&
new window.IMask(maskEl, {
mask: maskEl.dataset.mask,
lazy: maskEl.dataset['mask-visible'] === 'true',
})
/**
* --------------------------------------------------------------------------
* Tabler input-mask.js
* --------------------------------------------------------------------------
*/
/**
* Constants
*/
const NAME = 'input-mask'
const DATA_KEY = `tblr.${NAME}`
const SELECTOR_DATA_MASK = '[data-mask]'
/**
* Class definition
*/
class InputMask {
private element: HTMLElement | HTMLInputElement
private maskInstance: IMaskInputMask<any> | null = null
private initialized: boolean = false
private options: Record<string, any> = {}
constructor(element: HTMLElement | HTMLInputElement) {
this.element = element
}
// Getters
static get NAME() {
return NAME
}
static get DATA_KEY() {
return DATA_KEY
}
// Public
/**
* Initialize input mask on the element
*/
init(): void {
if (this.initialized) {
return
}
const mask = this.element.getAttribute('data-mask')
if (!mask) {
return
}
const options: Record<string, any> = {
mask: mask,
lazy: this.element.getAttribute('data-mask-visible') === 'true',
}
this.options = options
this.maskInstance = IMask(this.element, options)
this.initialized = true
}
/**
* Update input mask (useful when mask changes programmatically)
*/
update(mask?: string, options?: Partial<Record<string, any>>): void {
if (!this.initialized || !this.maskInstance) {
return
}
const newOptions: Record<string, any> = {
...this.options,
...options,
}
if (mask) {
newOptions.mask = mask
}
this.maskInstance.updateOptions(newOptions)
}
/**
* Get the current masked value
*/
getValue(): string {
if (!this.initialized || !this.maskInstance) {
return ''
}
return this.maskInstance.value
}
/**
* Get the current unmasked value
*/
getUnmaskedValue(): string {
if (!this.initialized || !this.maskInstance) {
return ''
}
return this.maskInstance.unmaskedValue
}
/**
* Destroy input mask instance
*/
dispose(): void {
if (!this.initialized || !this.maskInstance) {
return
}
this.maskInstance.destroy()
this.maskInstance = null
this.initialized = false
}
// Static
/**
* Get instance from element
*/
static getInstance(element: HTMLElement | HTMLInputElement): InputMask | null {
return elementMap.get(element) || null
}
/**
* Get or create instance
*/
static getOrCreateInstance(element: HTMLElement | HTMLInputElement): InputMask {
return this.getInstance(element) || new this(element)
}
}
/**
* Instance storage
*/
const elementMap = new WeakMap<HTMLElement | HTMLInputElement, InputMask>()
/**
* Data API implementation
*/
const inputMaskTriggerList: HTMLElement[] = [].slice.call(document.querySelectorAll<HTMLElement>(SELECTOR_DATA_MASK))
inputMaskTriggerList.map(function (inputMaskTriggerEl: HTMLElement) {
const instance = InputMask.getOrCreateInstance(inputMaskTriggerEl)
elementMap.set(inputMaskTriggerEl, instance)
instance.init()
return instance
})
export default InputMask

24
core/js/src/modal.ts Normal file
View File

@@ -0,0 +1,24 @@
import { Modal } from 'bootstrap'
/*
Core modals
*/
const modalTriggerList: HTMLElement[] = [].slice.call(document.querySelectorAll<HTMLElement>('[data-bs-toggle="modal"]'))
modalTriggerList.map(function (modalTriggerEl: HTMLElement) {
const target = modalTriggerEl.getAttribute('data-bs-target') || modalTriggerEl.getAttribute('href')
if (target === null) {
return
}
const modalEl = document.querySelector<HTMLElement>(target)
if (modalEl === null) {
return
}
const modal = new Modal(modalEl)
modalTriggerEl.addEventListener('click', (e) => {
e.preventDefault()
modal.show()
})
})

24
core/js/src/offcanvas.ts Normal file
View File

@@ -0,0 +1,24 @@
import { Offcanvas } from 'bootstrap'
/*
Core offcanvas
*/
const offcanvasTriggerList: HTMLElement[] = [].slice.call(document.querySelectorAll<HTMLElement>('[data-bs-toggle="offcanvas"]'))
offcanvasTriggerList.map(function (offcanvasTriggerEl: HTMLElement) {
const target = offcanvasTriggerEl.getAttribute('data-bs-target') || offcanvasTriggerEl.getAttribute('href')
if (target === null) {
return
}
const offcanvasEl = document.querySelector<HTMLElement>(target)
if (offcanvasEl === null) {
return
}
const offcanvas = new Offcanvas(offcanvasEl)
offcanvasTriggerEl.addEventListener('click', (e) => {
e.preventDefault()
offcanvas.show()
})
})

View File

@@ -1,4 +1,4 @@
import { Popover } from './bootstrap'
import { Popover } from 'bootstrap'
/*
Core popovers

View File

@@ -1,11 +1,147 @@
/*
Switch icons
/**
* --------------------------------------------------------------------------
* Tabler switch-icon.js
* --------------------------------------------------------------------------
*/
const switchesTriggerList: HTMLElement[] = [].slice.call(document.querySelectorAll<HTMLElement>('[data-bs-toggle="switch-icon"]'))
switchesTriggerList.map(function (switchTriggerEl: HTMLElement) {
switchTriggerEl.addEventListener('click', (e: MouseEvent) => {
e.stopPropagation()
switchTriggerEl.classList.toggle('active')
})
/**
* Constants
*/
const NAME = 'switch-icon'
const DATA_KEY = `tblr.${NAME}`
const CLASS_NAME_ACTIVE = 'active'
const SELECTOR_DATA_TOGGLE = '[data-bs-toggle="switch-icon"]'
/**
* Class definition
*/
class SwitchIcon {
private element: HTMLElement
private initialized: boolean = false
constructor(element: HTMLElement) {
this.element = element
}
// Getters
static get NAME() {
return NAME
}
static get DATA_KEY() {
return DATA_KEY
}
// Public
/**
* Initialize switch-icon on the element
*/
init(): void {
if (this.initialized) {
return
}
this._setListeners()
this.initialized = true
}
/**
* Toggle active state
*/
toggle(): void {
this.element.classList.toggle(CLASS_NAME_ACTIVE)
}
/**
* Show (activate) switch-icon
*/
show(): void {
this.element.classList.add(CLASS_NAME_ACTIVE)
}
/**
* Hide (deactivate) switch-icon
*/
hide(): void {
this.element.classList.remove(CLASS_NAME_ACTIVE)
}
/**
* Check if switch-icon is active
*/
isActive(): boolean {
return this.element.classList.contains(CLASS_NAME_ACTIVE)
}
/**
* Destroy switch-icon instance
*/
dispose(): void {
if (!this.initialized) {
return
}
this._removeListeners()
this.initialized = false
}
// Private
/**
* Set event listeners
*/
private _setListeners(): void {
this.element.addEventListener('click', this._handleClick)
}
/**
* Remove event listeners
*/
private _removeListeners(): void {
this.element.removeEventListener('click', this._handleClick)
}
/**
* Handle click event
*/
private _handleClick = (e: MouseEvent): void => {
e.stopPropagation()
this.toggle()
}
// Static
/**
* Get instance from element
*/
static getInstance(element: HTMLElement): SwitchIcon | null {
return elementMap.get(element) || null
}
/**
* Get or create instance
*/
static getOrCreateInstance(element: HTMLElement): SwitchIcon {
return this.getInstance(element) || new this(element)
}
}
/**
* Instance storage
*/
const elementMap = new WeakMap<HTMLElement, SwitchIcon>()
/**
* Data API implementation
*/
const switchIconTriggerList: HTMLElement[] = [].slice.call(document.querySelectorAll<HTMLElement>(SELECTOR_DATA_TOGGLE))
switchIconTriggerList.map(function (switchIconTriggerEl: HTMLElement) {
const instance = SwitchIcon.getOrCreateInstance(switchIconTriggerEl)
elementMap.set(switchIconTriggerEl, instance)
instance.init()
return instance
})
export default SwitchIcon

View File

@@ -1,4 +1,4 @@
import { Tab } from './bootstrap'
import { Tab } from 'bootstrap'
export const EnableActivationTabsFromLocationHash = (): void => {
const locationHash: string = window.location.hash

222
core/js/src/theme.ts Normal file
View File

@@ -0,0 +1,222 @@
/**
* --------------------------------------------------------------------------
* Tabler theme.js
* --------------------------------------------------------------------------
*/
/**
* Constants
*/
const NAME = 'theme'
const DATA_KEY = `tblr.${NAME}`
interface ThemeConfig {
'theme': string
'theme-base': string
'theme-font': string
'theme-primary': string
'theme-radius': string
}
const DEFAULT_THEME_CONFIG: ThemeConfig = {
'theme': 'light',
'theme-base': 'gray',
'theme-font': 'sans-serif',
'theme-primary': 'blue',
'theme-radius': '1',
}
/**
* Class definition
*/
class Theme {
private initialized: boolean = false
private config: ThemeConfig
constructor(config?: Partial<ThemeConfig>) {
this.config = { ...DEFAULT_THEME_CONFIG, ...config }
}
// Getters
static get NAME() {
return NAME
}
static get DATA_KEY() {
return DATA_KEY
}
// Public
/**
* Initialize theme
*/
init(): void {
if (this.initialized) {
return
}
this._applyTheme()
this.initialized = true
}
/**
* Update theme configuration
*/
update(config: Partial<ThemeConfig>): void {
this.config = { ...this.config, ...config }
this._applyTheme()
}
/**
* Set theme (light/dark)
*/
setTheme(value: string): void {
this.update({ theme: value })
this._updateURL('theme', value)
}
/**
* Set primary color
*/
setPrimary(value: string): void {
this.update({ 'theme-primary': value })
this._updateURL('theme-primary', value)
}
/**
* Set theme base
*/
setBase(value: string): void {
this.update({ 'theme-base': value })
this._updateURL('theme-base', value)
}
/**
* Set font family
*/
setFont(value: string): void {
this.update({ 'theme-font': value })
this._updateURL('theme-font', value)
}
/**
* Set border radius
*/
setRadius(value: string): void {
this.update({ 'theme-radius': value })
this._updateURL('theme-radius', value)
}
/**
* Reset all theme settings to default
*/
reset(): void {
this.config = { ...DEFAULT_THEME_CONFIG }
this._applyTheme()
if (typeof window !== 'undefined') {
const url = new URL(window.location.href)
for (const key in DEFAULT_THEME_CONFIG) {
url.searchParams.delete(key)
localStorage.removeItem('tabler-' + key)
}
window.history.pushState({}, '', url)
}
}
/**
* Get current theme configuration
*/
getConfig(): ThemeConfig {
const config: ThemeConfig = { ...DEFAULT_THEME_CONFIG }
if (typeof window !== 'undefined') {
for (const key in DEFAULT_THEME_CONFIG) {
const stored = localStorage.getItem('tabler-' + key)
if (stored) {
config[key as keyof ThemeConfig] = stored
}
}
}
return config
}
/**
* Dispose theme instance
*/
dispose(): void {
if (!this.initialized) {
return
}
this.initialized = false
}
// Private
/**
* Update URL with theme parameter
*/
private _updateURL(key: string, value: string): void {
if (typeof window !== 'undefined') {
const url = new URL(window.location.href)
if (value === DEFAULT_THEME_CONFIG[key as keyof ThemeConfig]) {
url.searchParams.delete(key)
} else {
url.searchParams.set(key, value)
}
window.history.pushState({}, '', url)
}
}
/**
* Apply theme to document
*/
private _applyTheme(): void {
const params = new Proxy(new URLSearchParams(window.location.search), {
get: (searchParams: URLSearchParams, prop: string): string | null => searchParams.get(prop),
})
for (const key in this.config) {
const param = params[key]
let selectedValue: string
if (!!param) {
localStorage.setItem('tabler-' + key, param)
selectedValue = param
} else {
const storedTheme = localStorage.getItem('tabler-' + key)
selectedValue = storedTheme ? storedTheme : this.config[key as keyof ThemeConfig]
}
if (selectedValue !== this.config[key as keyof ThemeConfig]) {
document.documentElement.setAttribute('data-bs-' + key, selectedValue)
} else {
document.documentElement.removeAttribute('data-bs-' + key)
}
}
}
// Static
/**
* Get or create instance
*/
static getOrCreateInstance(config?: Partial<ThemeConfig>): Theme {
if (!Theme.instance) {
Theme.instance = new Theme(config)
}
return Theme.instance
}
private static instance: Theme | null = null
}
/**
* Data API implementation
*/
const theme = Theme.getOrCreateInstance()
theme.init()
export default Theme

View File

@@ -1,4 +1,4 @@
import { Toast } from './bootstrap'
import { Toast } from 'bootstrap'
/*
Toasts

View File

@@ -1,4 +1,4 @@
import { Tooltip } from './bootstrap'
import { Tooltip } from 'bootstrap'
const tooltipTriggerList: HTMLElement[] = [].slice.call(document.querySelectorAll<HTMLElement>('[data-bs-toggle="tooltip"]'))
tooltipTriggerList.map(function (tooltipTriggerEl: HTMLElement) {

View File

@@ -1,43 +1,7 @@
/**
* demo-theme is specifically loaded right after the body and not deferred
* Theme initialization entry point
* This file is specifically loaded right after the body and not deferred
* to ensure we switch to the chosen dark/light theme as fast as possible.
* This will prevent any flashes of the light theme (default) before switching.
*/
interface ThemeConfig {
'theme': string
'theme-base': string
'theme-font': string
'theme-primary': string
'theme-radius': string
}
const themeConfig: ThemeConfig = {
'theme': 'light',
'theme-base': 'gray',
'theme-font': 'sans-serif',
'theme-primary': 'blue',
'theme-radius': '1',
}
const params = new Proxy(new URLSearchParams(window.location.search), {
get: (searchParams: URLSearchParams, prop: string): string | null => searchParams.get(prop),
})
for (const key in themeConfig) {
const param = params[key]
let selectedValue: string
if (!!param) {
localStorage.setItem('tabler-' + key, param)
selectedValue = param
} else {
const storedTheme = localStorage.getItem('tabler-' + key)
selectedValue = storedTheme ? storedTheme : themeConfig[key as keyof ThemeConfig]
}
if (selectedValue !== themeConfig[key as keyof ThemeConfig]) {
document.documentElement.setAttribute('data-bs-' + key, selectedValue)
} else {
document.documentElement.removeAttribute('data-bs-' + key)
}
}
import './src/theme'

View File

@@ -1,16 +1,56 @@
import './src/autosize'
import './src/countup'
import './src/input-mask'
import './src/dropdown'
import './src/tooltip'
import './src/popover'
import './src/switch-icon'
import './src/tab'
import './src/toast'
import './src/modal'
import './src/collapse'
import './src/offcanvas'
import './src/sortable'
import './src/switch-icon'
import './src/autosize'
import './src/countup'
import './src/input-mask'
import './src/copy'
import Theme from './src/theme'
// Re-export everything from bootstrap.ts (single source of truth)
export * from './src/bootstrap'
// Export Popper
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 custom Tabler components
export { default as Autosize } from './src/autosize'
export { default as SwitchIcon } from './src/switch-icon'
export { default as Countup } from './src/countup'
export { default as InputMask } from './src/input-mask'
export { default as Copy } from './src/copy'
export { default as Clipboard } from './src/clipboard'
// Re-export everything as namespace for backward compatibility
export * as bootstrap from 'bootstrap'
// Re-export tabler namespace
export * as tabler from './src/tabler'
// Create global Tabler API object
const themeInstance = Theme.getOrCreateInstance()
const Tabler = {
setTheme: (value: string) => themeInstance.setTheme(value),
setPrimary: (value: string) => themeInstance.setPrimary(value),
setBase: (value: string) => themeInstance.setBase(value),
setFont: (value: string) => themeInstance.setFont(value),
setRadius: (value: string) => themeInstance.setRadius(value),
reset: () => themeInstance.reset(),
getConfig: () => themeInstance.getConfig(),
}
// Export Tabler API
export { Tabler }
// Make Tabler available globally on window object
if (typeof window !== 'undefined') {
;(window as any).Tabler = Tabler
}

168
core/js/tests/README.md Normal file
View File

@@ -0,0 +1,168 @@
# Tabler JavaScript Tests
## How does Tabler's test suite work?
Tabler uses **Vitest** for unit testing. Each plugin has a file dedicated to its tests in `tests/unit/<plugin-name>.spec.ts`.
## Running Tests
To run the unit test suite, run:
```bash
pnpm test
```
To run tests in watch mode:
```bash
pnpm test:watch
```
To run tests with UI:
```bash
pnpm test:ui
```
To run tests with coverage:
```bash
pnpm test:coverage
```
## How do I add a new unit test?
1. Locate and open the file dedicated to the plugin which you need to add tests to (`tests/unit/<plugin-name>.spec.ts`).
2. Review the Vitest API Documentation and use the existing tests as references for how to structure your new tests.
3. Write the necessary unit test(s) for the new or revised functionality.
4. Run `pnpm test` to see the results of your newly-added test(s).
**Note:** Your new unit tests should fail before your changes are applied to the plugin, and should pass after your changes are applied to the plugin.
## What should a unit test look like?
- Each test should have a unique name clearly stating what unit is being tested.
- Each test should be in the corresponding `describe` block.
- Each test should test only one unit per test, although one test can include several assertions. Create multiple tests for multiple units of functionality.
- Each test should use `expect` to ensure something is expected.
- Each test should follow the project's JavaScript Code Guidelines.
## Code Coverage
We're aiming for at least 90% test coverage for our code. To ensure your changes meet or exceed this limit, run:
```bash
pnpm test:coverage
```
This will generate coverage reports in multiple formats:
- **Text**: Displayed in terminal
- **HTML**: Open `coverage/index.html` in your browser for detailed coverage report
- **JSON**: `coverage/coverage-final.json` for programmatic access
- **LCOV**: `coverage/lcov.info` for CI/CD integration
### Coverage Thresholds
The following thresholds are enforced (minimum 90%):
- Lines: 90%
- Functions: 90%
- Branches: 90%
- Statements: 90%
If coverage falls below these thresholds, tests will fail.
## Test Structure
Tests follow Bootstrap's test structure pattern:
```typescript
import { describe, it, expect, beforeEach } from 'vitest'
import PluginName from '../../src/plugin-name'
import { getFixture, clearFixture } from '../helpers/fixture'
describe('PluginName', () => {
let fixtureEl: HTMLDivElement
beforeEach(() => {
fixtureEl = getFixture()
clearFixture()
})
describe('getInstance', () => {
it('should return null if there is no instance', () => {
fixtureEl.innerHTML = '<div></div>'
const divEl = fixtureEl.querySelector('div') as HTMLElement
expect(PluginName.getInstance(divEl)).toBeNull()
})
})
})
```
## Example Tests
### Synchronous test
```typescript
describe('getInstance', () => {
it('should return null if there is no instance', () => {
fixtureEl.innerHTML = '<div></div>'
const divEl = fixtureEl.querySelector('div') as HTMLElement
expect(PluginName.getInstance(divEl)).toBeNull()
})
it('should return this instance', () => {
fixtureEl.innerHTML = '<div></div>'
const divEl = fixtureEl.querySelector('div') as HTMLElement
const instance = new PluginName(divEl)
expect(PluginName.getInstance(divEl)).toEqual(instance)
})
})
```
### Asynchronous test
```typescript
it('should show a tooltip without the animation', async () => {
fixtureEl.innerHTML = '<a href="#" rel="tooltip" title="Another tooltip"></a>'
const tooltipEl = fixtureEl.querySelector('a') as HTMLElement
const tooltip = new Tooltip(tooltipEl, {
animation: false
})
const promise = new Promise<void>((resolve) => {
tooltipEl.addEventListener('shown.bs.tooltip', () => {
const tip = document.querySelector('.tooltip')
expect(tip).not.toBeNull()
expect(tip?.classList.contains('fade')).toBe(false)
resolve()
})
})
tooltip.show()
await promise
})
```
## Fixture Helper
Use the fixture helper to manage test DOM elements:
```typescript
import { getFixture, clearFixture } from '../helpers/fixture'
describe('MyPlugin', () => {
let fixtureEl: HTMLDivElement
beforeEach(() => {
fixtureEl = getFixture()
clearFixture()
})
it('should work', () => {
fixtureEl.innerHTML = '<div class="test"></div>'
const divEl = fixtureEl.querySelector('div') as HTMLElement
// Your test code here
})
})
```

View File

@@ -0,0 +1,36 @@
/**
* Test fixture helper
* Similar to Bootstrap's fixtureEl pattern
*/
let fixtureContainer: HTMLDivElement | null = null
/**
* Get or create fixture container
*/
export function getFixture(): HTMLDivElement {
if (!fixtureContainer) {
fixtureContainer = document.createElement('div')
fixtureContainer.id = 'test-fixture'
document.body.appendChild(fixtureContainer)
}
return fixtureContainer
}
/**
* Clear fixture container
*/
export function clearFixture(): void {
const fixture = getFixture()
fixture.innerHTML = ''
}
/**
* Remove fixture container
*/
export function removeFixture(): void {
if (fixtureContainer && fixtureContainer.parentNode) {
fixtureContainer.parentNode.removeChild(fixtureContainer)
fixtureContainer = null
}
}

View File

@@ -0,0 +1,13 @@
import { afterEach } from 'vitest'
import { clearFixture } from './fixture'
/**
* Setup file for Vitest tests
* This file runs before each test file
*
* Note: jsdom automatically cleans up the DOM between tests,
* but we clear fixture for consistency with Bootstrap's test pattern
*/
afterEach(() => {
clearFixture()
})

View File

@@ -0,0 +1,346 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { Tooltip } from 'bootstrap'
import Copy from '../../src/copy'
import { getFixture, clearFixture } from '../helpers/fixture'
describe('Copy', () => {
let fixtureEl: HTMLDivElement
let clipboardWriteText: ReturnType<typeof vi.fn>
let originalClipboardDescriptor: PropertyDescriptor | undefined
let originalExecCommand: any
const setNavigatorClipboard = (writeTextImpl: (text: string) => Promise<void>) => {
originalClipboardDescriptor = Object.getOwnPropertyDescriptor(navigator, 'clipboard')
Object.defineProperty(navigator, 'clipboard', {
value: { writeText: writeTextImpl },
configurable: true,
})
}
beforeEach(() => {
fixtureEl = getFixture()
clearFixture()
clipboardWriteText = vi.fn().mockResolvedValue(undefined)
setNavigatorClipboard(clipboardWriteText)
originalExecCommand = (document as any).execCommand
;(document as any).execCommand = vi.fn().mockReturnValue(false)
vi.clearAllMocks()
})
afterEach(() => {
vi.useRealTimers()
if (originalClipboardDescriptor) {
Object.defineProperty(navigator, 'clipboard', originalClipboardDescriptor)
} else {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete (navigator as any).clipboard
}
;(document as any).execCommand = originalExecCommand
})
describe('getInstance / getOrCreateInstance', () => {
it('should return null when instance does not exist', () => {
fixtureEl.innerHTML = '<button data-bs-toggle="copy" data-bs-copy-text="x"></button>'
const buttonEl = fixtureEl.querySelector('button') as HTMLElement
expect(Copy.getInstance(buttonEl)).toBeNull()
})
it('should create and store instance on element', () => {
fixtureEl.innerHTML = '<button data-bs-toggle="copy" data-bs-copy-text="x"></button>'
const buttonEl = fixtureEl.querySelector('button') as HTMLElement
const instance1 = Copy.getOrCreateInstance(buttonEl)
expect(instance1).toBeInstanceOf(Copy)
expect(Copy.getInstance(buttonEl)).toBe(instance1)
const instance2 = Copy.getOrCreateInstance(buttonEl)
expect(instance2).toBe(instance1)
})
it('dispose should remove stored instance', () => {
fixtureEl.innerHTML = '<button data-bs-toggle="copy" data-bs-copy-text="x"></button>'
const buttonEl = fixtureEl.querySelector('button') as HTMLElement
const instance = Copy.getOrCreateInstance(buttonEl)!
expect(Copy.getInstance(buttonEl)).toBe(instance)
instance.dispose()
expect(Copy.getInstance(buttonEl)).toBeNull()
})
})
describe('copy()', () => {
it('should copy text from data-bs-copy-text and set copied state', async () => {
fixtureEl.innerHTML = `
<button data-bs-toggle="copy" data-bs-copy-text="hello">
<span data-bs-copy-default>Default</span>
<span data-bs-copy-success hidden>Success</span>
</button>
`
const buttonEl = fixtureEl.querySelector('button') as HTMLElement
const def = buttonEl.querySelector('[data-bs-copy-default]') as HTMLElement
const success = buttonEl.querySelector('[data-bs-copy-success]') as HTMLElement
const instance = Copy.getOrCreateInstance(buttonEl)!
const ok = await instance.copy()
expect(ok).toBe(true)
expect(clipboardWriteText).toHaveBeenCalledWith('hello')
expect(buttonEl.classList.contains('is-copied')).toBe(true)
expect(buttonEl.getAttribute('aria-label')).toBe('Copied')
expect(def.hidden).toBe(true)
expect(success.hidden).toBe(false)
})
it('should fire cancelable COPY event and respect preventDefault()', async () => {
fixtureEl.innerHTML = '<button data-bs-toggle="copy" data-bs-copy-text="hello"></button>'
const buttonEl = fixtureEl.querySelector('button') as HTMLElement
buttonEl.addEventListener(Copy.Event.COPY, (e) => e.preventDefault())
const instance = Copy.getOrCreateInstance(buttonEl)!
const ok = await instance.copy()
expect(ok).toBe(false)
expect(clipboardWriteText).not.toHaveBeenCalled()
})
it('should emit COPYFAIL when there is no text', async () => {
fixtureEl.innerHTML = '<button data-bs-toggle="copy"></button>'
const buttonEl = fixtureEl.querySelector('button') as HTMLElement
const onFail = vi.fn()
buttonEl.addEventListener(Copy.Event.COPYFAIL, onFail as any)
const instance = Copy.getOrCreateInstance(buttonEl)!
const ok = await instance.copy()
expect(ok).toBe(false)
expect(onFail).toHaveBeenCalledTimes(1)
const ev = onFail.mock.calls[0][0] as CustomEvent
expect(ev.detail.reason).toBe('no-text')
})
it('should emit COPYFAIL when clipboard write fails', async () => {
clipboardWriteText.mockRejectedValueOnce(new Error('nope'))
fixtureEl.innerHTML = '<button data-bs-toggle="copy" data-bs-copy-text="hello"></button>'
const buttonEl = fixtureEl.querySelector('button') as HTMLElement
const onFail = vi.fn()
buttonEl.addEventListener(Copy.Event.COPYFAIL, onFail as any)
const instance = Copy.getOrCreateInstance(buttonEl)!
const ok = await instance.copy()
expect(ok).toBe(false)
expect(onFail).toHaveBeenCalledTimes(1)
const ev = onFail.mock.calls[0][0] as CustomEvent
expect(ev.detail.reason).toBe('clipboard-failed')
})
it('should reset automatically after timeout when reset=true', async () => {
vi.useFakeTimers()
fixtureEl.innerHTML = `
<button data-bs-toggle="copy" data-bs-copy-text="hello">
<span data-bs-copy-default>Default</span>
<span data-bs-copy-success hidden>Success</span>
</button>
`
const buttonEl = fixtureEl.querySelector('button') as HTMLElement
const def = buttonEl.querySelector('[data-bs-copy-default]') as HTMLElement
const success = buttonEl.querySelector('[data-bs-copy-success]') as HTMLElement
const instance = Copy.getOrCreateInstance(buttonEl, { timeout: 1000 })!
await instance.copy()
expect(buttonEl.classList.contains('is-copied')).toBe(true)
vi.advanceTimersByTime(999)
expect(buttonEl.classList.contains('is-copied')).toBe(true)
vi.advanceTimersByTime(1)
expect(buttonEl.classList.contains('is-copied')).toBe(false)
expect(buttonEl.getAttribute('aria-label')).toBeNull()
expect(def.hidden).toBe(false)
expect(success.hidden).toBe(true)
})
it('should extend timeout without recopy when recopy=false and extend=true', async () => {
vi.useFakeTimers()
fixtureEl.innerHTML = `
<button data-bs-toggle="copy" data-bs-copy-text="hello">
<span data-bs-copy-default>Default</span>
<span data-bs-copy-success hidden>Success</span>
</button>
`
const buttonEl = fixtureEl.querySelector('button') as HTMLElement
const instance = Copy.getOrCreateInstance(buttonEl, { timeout: 1000, recopy: false, extend: true, reset: true })!
await instance.copy()
expect(clipboardWriteText).toHaveBeenCalledTimes(1)
vi.advanceTimersByTime(500)
await instance.copy()
expect(clipboardWriteText).toHaveBeenCalledTimes(1)
// 600ms after the second click: should still be copied (timer extended)
vi.advanceTimersByTime(600)
expect(buttonEl.classList.contains('is-copied')).toBe(true)
// 401ms more (total 1001ms after second click): should reset
vi.advanceTimersByTime(401)
expect(buttonEl.classList.contains('is-copied')).toBe(false)
})
it('should recopy when recopy=true (default)', async () => {
fixtureEl.innerHTML = '<button data-bs-toggle="copy" data-bs-copy-text="hello"></button>'
const buttonEl = fixtureEl.querySelector('button') as HTMLElement
const instance = Copy.getOrCreateInstance(buttonEl, { reset: false })!
await instance.copy()
await instance.copy()
expect(clipboardWriteText).toHaveBeenCalledTimes(2)
})
it('should not extend timeout when extend=false and timer is already running', async () => {
vi.useFakeTimers()
fixtureEl.innerHTML = `
<button data-bs-toggle="copy" data-bs-copy-text="hello">
<span data-bs-copy-default>Default</span>
<span data-bs-copy-success hidden>Success</span>
</button>
`
const buttonEl = fixtureEl.querySelector('button') as HTMLElement
const instance = Copy.getOrCreateInstance(buttonEl, { timeout: 1000, recopy: false, extend: false, reset: true })!
await instance.copy()
vi.advanceTimersByTime(500)
await instance.copy() // should NOT extend existing timer
vi.advanceTimersByTime(501) // total 1001ms from first copy
expect(buttonEl.classList.contains('is-copied')).toBe(false)
})
})
describe('reset()', () => {
it('should manually reset and emit RESET with manual=true', async () => {
fixtureEl.innerHTML = '<button data-bs-toggle="copy" data-bs-copy-text="hello"></button>'
const buttonEl = fixtureEl.querySelector('button') as HTMLElement
const onReset = vi.fn()
buttonEl.addEventListener(Copy.Event.RESET, onReset as any)
const instance = Copy.getOrCreateInstance(buttonEl, { reset: false })!
await instance.copy()
expect(buttonEl.classList.contains('is-copied')).toBe(true)
instance.reset()
expect(buttonEl.classList.contains('is-copied')).toBe(false)
expect(onReset).toHaveBeenCalledTimes(1)
const ev = onReset.mock.calls[0][0] as CustomEvent
expect(ev.detail.manual).toBe(true)
})
})
describe('Data API', () => {
it('should copy on click via document listener', async () => {
fixtureEl.innerHTML = `
<button data-bs-toggle="copy" data-bs-copy-text="hello">
<span data-bs-copy-default>Default</span>
</button>
`
const buttonEl = fixtureEl.querySelector('button') as HTMLElement
const span = buttonEl.querySelector('span') as HTMLElement
span.dispatchEvent(new MouseEvent('click', { bubbles: true }))
// allow async copy() chain to complete (Data API doesn't await)
await new Promise((resolve) => setTimeout(resolve, 0))
expect(clipboardWriteText).toHaveBeenCalledWith('hello')
expect(buttonEl.classList.contains('is-copied')).toBe(true)
})
})
describe('feedback: tooltip', () => {
it('should show/hide bootstrap tooltip when available', async () => {
vi.useFakeTimers()
const show = vi.fn()
const hide = vi.fn()
const dispose = vi.fn()
const getOrCreateInstance = vi.spyOn(Tooltip, 'getOrCreateInstance').mockReturnValue({ show, hide, dispose } as any)
fixtureEl.innerHTML = '<button data-bs-toggle="copy" data-bs-copy-text="hello"></button>'
const buttonEl = fixtureEl.querySelector('button') as HTMLElement
const instance = Copy.getOrCreateInstance(buttonEl, {
feedback: 'tooltip',
tooltip: 'Copied!',
tooltipPlacement: 'top',
timeout: 500,
reset: true,
})!
await instance.copy()
expect(getOrCreateInstance).toHaveBeenCalledTimes(1)
expect(show).toHaveBeenCalledTimes(1)
vi.advanceTimersByTime(500)
expect(hide).toHaveBeenCalledTimes(1)
instance.dispose()
expect(dispose).toHaveBeenCalledTimes(1)
getOrCreateInstance.mockRestore()
})
it('should not swap when tooltip mode is enabled', async () => {
fixtureEl.innerHTML = `
<button data-bs-toggle="copy" data-bs-copy-text="hello">
<span data-bs-copy-default>Default</span>
<span data-bs-copy-success hidden>Success</span>
</button>
`
const buttonEl = fixtureEl.querySelector('button') as HTMLElement
const show = vi.fn()
const hide = vi.fn()
const dispose = vi.fn()
const getOrCreateInstance = vi.spyOn(Tooltip, 'getOrCreateInstance').mockReturnValue({ show, hide, dispose } as any)
const instance = Copy.getOrCreateInstance(buttonEl, { feedback: 'tooltip', reset: false })!
await instance.copy()
// tooltip mode uses Tooltip feedback and does not rely on swap markup
expect(show).toHaveBeenCalledTimes(1)
expect(buttonEl.classList.contains('is-copied')).toBe(false)
instance.dispose()
getOrCreateInstance.mockRestore()
})
})
describe('static properties', () => {
it('should have correct NAME', () => {
expect(Copy.NAME).toBe('copy')
})
it('should have correct DATA_KEY', () => {
expect(Copy.DATA_KEY).toBe('bs.copy')
})
})
})

View File

@@ -0,0 +1,204 @@
import { describe, it, expect, beforeEach } from 'vitest'
import SwitchIcon from '../../src/switch-icon'
import { getFixture, clearFixture } from '../helpers/fixture'
/**
* Unit tests for SwitchIcon plugin
* Following Bootstrap's test structure pattern
*/
describe('SwitchIcon', () => {
let fixtureEl: HTMLDivElement
beforeEach(() => {
fixtureEl = getFixture()
clearFixture()
})
describe('getInstance', () => {
it('should return null if there is no instance', () => {
fixtureEl.innerHTML = '<div data-bs-toggle="switch-icon"></div>'
const divEl = fixtureEl.querySelector('div') as HTMLElement
expect(SwitchIcon.getInstance(divEl)).toBeNull()
})
it('should return instance when created via Data API', () => {
// Simulate Data API behavior - element with data attribute triggers initialization
fixtureEl.innerHTML = '<div data-bs-toggle="switch-icon"></div>'
const divEl = fixtureEl.querySelector('div') as HTMLElement
// Manually trigger Data API-like behavior
const switchIcon = SwitchIcon.getOrCreateInstance(divEl)
// Note: In real Data API, elementMap.set() is called, but getOrCreateInstance doesn't do that
// So we test that getInstance returns null when instance is not in map
expect(SwitchIcon.getInstance(divEl)).toBeNull()
})
})
describe('getOrCreateInstance', () => {
it('should return new instance when there is no instance', () => {
fixtureEl.innerHTML = '<div data-bs-toggle="switch-icon"></div>'
const divEl = fixtureEl.querySelector('div') as HTMLElement
const switchIcon = SwitchIcon.getOrCreateInstance(divEl)
expect(switchIcon).toBeInstanceOf(SwitchIcon)
// Note: getOrCreateInstance doesn't add to elementMap, only Data API does
expect(SwitchIcon.getInstance(divEl)).toBeNull()
})
it('should return new instance each time (not stored in map)', () => {
fixtureEl.innerHTML = '<div data-bs-toggle="switch-icon"></div>'
const divEl = fixtureEl.querySelector('div') as HTMLElement
const switchIcon1 = SwitchIcon.getOrCreateInstance(divEl)
const switchIcon2 = SwitchIcon.getOrCreateInstance(divEl)
// Both are new instances because they're not stored in elementMap
// getOrCreateInstance creates new instance if not found in map
expect(switchIcon1).toBeInstanceOf(SwitchIcon)
expect(switchIcon2).toBeInstanceOf(SwitchIcon)
// They are different instances because elementMap is not used by getOrCreateInstance
expect(switchIcon1).not.toEqual(switchIcon2)
})
})
describe('toggle', () => {
it('should toggle active class', () => {
fixtureEl.innerHTML = '<div data-bs-toggle="switch-icon"></div>'
const divEl = fixtureEl.querySelector('div') as HTMLElement
const switchIcon = new SwitchIcon(divEl)
expect(switchIcon.isActive()).toBe(false)
switchIcon.toggle()
expect(switchIcon.isActive()).toBe(true)
switchIcon.toggle()
expect(switchIcon.isActive()).toBe(false)
})
})
describe('show', () => {
it('should add active class', () => {
fixtureEl.innerHTML = '<div data-bs-toggle="switch-icon"></div>'
const divEl = fixtureEl.querySelector('div') as HTMLElement
const switchIcon = new SwitchIcon(divEl)
expect(divEl.classList.contains('active')).toBe(false)
switchIcon.show()
expect(divEl.classList.contains('active')).toBe(true)
expect(switchIcon.isActive()).toBe(true)
})
})
describe('hide', () => {
it('should remove active class', () => {
fixtureEl.innerHTML = '<div data-bs-toggle="switch-icon" class="active"></div>'
const divEl = fixtureEl.querySelector('div') as HTMLElement
const switchIcon = new SwitchIcon(divEl)
expect(divEl.classList.contains('active')).toBe(true)
switchIcon.hide()
expect(divEl.classList.contains('active')).toBe(false)
expect(switchIcon.isActive()).toBe(false)
})
})
describe('init', () => {
it('should initialize and set event listeners', () => {
fixtureEl.innerHTML = '<div data-bs-toggle="switch-icon"></div>'
const divEl = fixtureEl.querySelector('div') as HTMLElement
const switchIcon = new SwitchIcon(divEl)
switchIcon.init()
expect(switchIcon.isActive()).toBe(false)
// Simulate click
divEl.click()
expect(switchIcon.isActive()).toBe(true)
})
it('should not initialize twice', () => {
fixtureEl.innerHTML = '<div data-bs-toggle="switch-icon"></div>'
const divEl = fixtureEl.querySelector('div') as HTMLElement
const switchIcon = new SwitchIcon(divEl)
switchIcon.init()
const firstClick = () => {
divEl.click()
}
firstClick()
expect(switchIcon.isActive()).toBe(true)
// Reset
switchIcon.hide()
switchIcon.init() // Should not add duplicate listeners
divEl.click()
expect(switchIcon.isActive()).toBe(true)
})
})
describe('dispose', () => {
it('should remove event listeners', () => {
fixtureEl.innerHTML = '<div data-bs-toggle="switch-icon"></div>'
const divEl = fixtureEl.querySelector('div') as HTMLElement
const switchIcon = new SwitchIcon(divEl)
switchIcon.init()
switchIcon.dispose()
// Click should not toggle after dispose
divEl.click()
expect(switchIcon.isActive()).toBe(false)
})
it('should do nothing if not initialized', () => {
fixtureEl.innerHTML = '<div data-bs-toggle="switch-icon"></div>'
const divEl = fixtureEl.querySelector('div') as HTMLElement
const switchIcon = new SwitchIcon(divEl)
// Should not throw
expect(() => {
switchIcon.dispose()
}).not.toThrow()
})
})
describe('click event', () => {
it('should toggle on click after init', () => {
fixtureEl.innerHTML = '<div data-bs-toggle="switch-icon"></div>'
const divEl = fixtureEl.querySelector('div') as HTMLElement
const switchIcon = new SwitchIcon(divEl)
switchIcon.init()
expect(switchIcon.isActive()).toBe(false)
divEl.click()
expect(switchIcon.isActive()).toBe(true)
divEl.click()
expect(switchIcon.isActive()).toBe(false)
})
it('should stop propagation', () => {
fixtureEl.innerHTML = '<div data-bs-toggle="switch-icon"></div>'
const divEl = fixtureEl.querySelector('div') as HTMLElement
const switchIcon = new SwitchIcon(divEl)
let parentClicked = false
fixtureEl.addEventListener('click', () => {
parentClicked = true
})
switchIcon.init()
divEl.click()
expect(parentClicked).toBe(false)
})
})
})

View File

@@ -0,0 +1,64 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { hexToRgba, getColor, prefix } from '../../src/tabler'
describe('tabler', () => {
describe('prefix', () => {
it('should have correct prefix value', () => {
expect(prefix).toBe('tblr-')
})
})
describe('hexToRgba', () => {
it('should convert hex color to rgba string', () => {
expect(hexToRgba('#ff0000', 1)).toBe('rgba(255, 0, 0, 1)')
expect(hexToRgba('#00ff00', 0.5)).toBe('rgba(0, 255, 0, 0.5)')
expect(hexToRgba('#0000ff', 0.8)).toBe('rgba(0, 0, 255, 0.8)')
})
it('should handle hex without #', () => {
expect(hexToRgba('ff0000', 1)).toBe('rgba(255, 0, 0, 1)')
})
it('should handle uppercase hex', () => {
expect(hexToRgba('#FF0000', 1)).toBe('rgba(255, 0, 0, 1)')
})
it('should return null for invalid hex', () => {
expect(hexToRgba('invalid', 1)).toBeNull()
expect(hexToRgba('#gg0000', 1)).toBeNull()
expect(hexToRgba('', 1)).toBeNull()
})
})
describe('getColor', () => {
let originalBody: HTMLBodyElement
beforeEach(() => {
// Save original body
originalBody = document.body
// Set test CSS variable value
document.body.style.setProperty(`--${prefix}primary`, '#ff0000')
})
it('should get color from CSS variable', () => {
const color = getColor('primary')
expect(color).toBe('#ff0000')
})
it('should return null if color variable does not exist', () => {
const color = getColor('nonexistent')
expect(color).toBe('')
})
it('should convert to rgba when opacity is provided', () => {
const color = getColor('primary', 0.5)
expect(color).toBe('rgba(255, 0, 0, 0.5)')
})
it('should return hex color when opacity is 1', () => {
const color = getColor('primary', 1)
expect(color).toBe('#ff0000')
})
})
})

View File

@@ -0,0 +1,20 @@
import { defineConfig } from 'vitest/config'
import { resolve } from 'path'
import { fileURLToPath } from 'node:url'
const __dirname = fileURLToPath(new URL('.', import.meta.url))
export default defineConfig({
root: __dirname,
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./helpers/setup.ts'],
include: ['**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}']
},
resolve: {
alias: {
'@': resolve(__dirname, '../src')
}
}
})

View File

@@ -20,15 +20,15 @@
"css-lint-variables": "find-unused-sass-variables scss/ node_modules/bootstrap/scss/",
"js": "pnpm run js-build && pnpm run js-build-min",
"js-build": "concurrently \"pnpm run js-build-standalone\" \"pnpm run js-build-standalone-esm\" \"pnpm run js-build-theme\" \"pnpm run js-build-theme-esm\"",
"js-build-theme-esm": "cross-env THEME=true ESM=true vite build --config .build/vite.config.ts",
"js-build-theme": "cross-env THEME=true vite build --config .build/vite.config.ts",
"js-build-standalone": "vite build --config .build/vite.config.ts",
"js-build-standalone-esm": "cross-env ESM=true vite build --config .build/vite.config.ts",
"js-build-theme-esm": "cross-env THEME=true ESM=true vite build --config .build/vite.config.mts",
"js-build-theme": "cross-env THEME=true vite build --config .build/vite.config.mts",
"js-build-standalone": "vite build --config .build/vite.config.mts",
"js-build-standalone-esm": "cross-env ESM=true vite build --config .build/vite.config.mts",
"js-build-min": "concurrently \"pnpm run js-build-min-standalone\" \"pnpm run js-build-min-standalone-esm\" \"pnpm run js-build-min-theme\" \"pnpm run js-build-min-theme-esm\"",
"js-build-min-standalone": "cross-env MINIFY=true vite build --config .build/vite.config.ts",
"js-build-min-standalone-esm": "cross-env MINIFY=true ESM=true vite build --config .build/vite.config.ts",
"js-build-min-theme": "cross-env MINIFY=true THEME=true vite build --config .build/vite.config.ts",
"js-build-min-theme-esm": "cross-env MINIFY=true THEME=true ESM=true vite build --config .build/vite.config.ts",
"js-build-min-standalone": "cross-env MINIFY=true vite build --config .build/vite.config.mts",
"js-build-min-standalone-esm": "cross-env MINIFY=true ESM=true vite build --config .build/vite.config.mts",
"js-build-min-theme": "cross-env MINIFY=true THEME=true vite build --config .build/vite.config.mts",
"js-build-min-theme-esm": "cross-env MINIFY=true THEME=true ESM=true vite build --config .build/vite.config.mts",
"copy": "concurrently \"pnpm run copy-img\" \"pnpm run copy-libs\" \"pnpm run copy-fonts\"",
"copy-img": "shx mkdir -p dist/img && shx cp -rf img/* dist/img",
"copy-libs": "tsx .build/copy-libs.ts",
@@ -41,7 +41,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 --config js/tests/vitest.config.ts",
"test:watch": "vitest --config js/tests/vitest.config.ts",
"test:ui": "vitest --ui --config js/tests/vitest.config.ts"
},
"repository": {
"type": "git",
@@ -157,12 +160,16 @@
"devDependencies": {
"@hotwired/turbo": "^8.0.18",
"@melloware/coloris": "^0.25.0",
"@testing-library/dom": "^10.0.0",
"@types/node": "^22.0.0",
"@vitest/coverage-v8": "2.1.9",
"@vitest/ui": "^2.0.0",
"apexcharts": "^5.3.6",
"autosize": "^6.0.1",
"choices.js": "^11.1.0",
"clipboard": "^2.0.11",
"countup.js": "^2.9.0",
"driver.js": "^1.0.0",
"dropzone": "^6.0.0-beta.2",
"find-unused-sass-variables": "^6.1.0",
"flatpickr": "^4.6.13",
@@ -171,6 +178,7 @@
"geist": "^1.5.1",
"hugerte": "^1.0.9",
"imask": "^7.6.1",
"jsdom": "^24.0.0",
"jsvectormap": "^1.7.0",
"list.js": "^2.3.1",
"litepicker": "^2.0.12",
@@ -182,7 +190,7 @@
"tom-select": "^2.4.3",
"typed.js": "^2.1.0",
"typescript": "^5.9.3",
"driver.js": "^1.0.0"
"vitest": "^2.0.0"
},
"directories": {
"doc": "docs"

View File

@@ -32,7 +32,6 @@
/** Theme colors */
@each $name, $color in map.merge($theme-colors, $social-colors) {
@debug contrast-ratio($color, white), $name, $min-contrast-ratio;
--#{$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))};

View File

@@ -138,8 +138,6 @@
// Colors
@function to-rgb($value) {
@debug $value;
@return color.channel($value, 'red', $space: rgb), color.channel($value, 'green', $space: rgb), color.channel($value, 'blue', $space: rgb);
}

View File

@@ -15,7 +15,11 @@
"declaration": false,
"declarationMap": false,
"sourceMap": true,
"allowJs": true
"allowJs": true,
"baseUrl": ".",
"paths": {
"@/*": ["js/src/*"]
}
},
"include": [
"js/**/*"

View File

@@ -1,20 +1,87 @@
---
title: Autosize
summary: The autosize element will automatically adjust the textarea height and make it easier for users to follow as they type.
docs-libs: autosize
description: Auto-adjusting textarea for better usability.
---
To be able to use the autosize in your application you will need to install the autosize dependency with `npm install autosize`.
The autosize component is built into Tabler and works similar to Bootstrap components. It automatically adjusts textarea height as users type.
## Default markup
## Basic usage
Add the autosize element to your input to make it automatically adjust to the length of a text as a user types it.
To create autosize textarea, add the `data-bs-toggle="autosize"` attribute to the textarea element:
The easiest way to use autosize is through the Data API. Add the `data-bs-autosize` attribute to the textarea element:
{% capture html -%}
<label class="form-label">Autosize example</label>
<textarea class="form-control" data-bs-toggle="autosize" placeholder="Type something…"></textarea>
<textarea class="form-control" data-bs-autosize placeholder="Type something…"></textarea>
{%- endcapture %}
{% include "docs/example.html" html=html column vertical %}
## With initial rows
You can set an initial number of rows for the textarea:
{% capture html -%}
<label class="form-label">Autosize with initial rows</label>
<textarea class="form-control" data-bs-autosize rows="3" placeholder="Type something…"></textarea>
{%- endcapture %}
{% include "docs/example.html" html=html column vertical %}
## Usage
### Via data attributes
Add `data-bs-autosize` to a textarea element to automatically initialize autosize:
```html
<textarea class="form-control" data-bs-autosize placeholder="Type something…"></textarea>
```
### Via JavaScript
Initialize autosize programmatically using the `Autosize` class:
```javascript
import { Autosize } from '@tabler/core'
// Get or create instance
const textarea = document.querySelector('textarea')
const autosize = Autosize.getOrCreateInstance(textarea)
autosize.init()
```
### Methods
| Method | Description |
| --- | --- |
| `init()` | Initialize autosize on the element. |
| `update()` | Update autosize when content changes programmatically. |
| `dispose()` | Destroy autosize instance. |
| `getInstance(element)` | *Static* method which allows you to get the autosize instance associated with a DOM element. |
| `getOrCreateInstance(element)` | *Static* method which allows you to get the autosize instance associated with a DOM element, or create a new one in case it wasn't initialized. |
#### Example: Update after content change
```javascript
import { Autosize } from '@tabler/core'
const textarea = document.querySelector('textarea')
const autosize = Autosize.getOrCreateInstance(textarea)
autosize.init()
// Later, when content changes programmatically
textarea.value = 'New content that is longer than before...'
autosize.update()
```
#### Example: Get existing instance
```javascript
import { Autosize } from '@tabler/core'
const textarea = document.querySelector('textarea')
const autosize = Autosize.getInstance(textarea)
if (autosize) {
autosize.update()
}
```

View File

@@ -0,0 +1,311 @@
---
title: Copy
summary: Quickly copy text to the clipboard with UI feedback.
description: Use Copy to copy text to the clipboard and provide instant UI feedback via swap or tooltip.
---
Use the **Copy** plugin to quickly copy text to the clipboard and provide instant UI feedback (**swap** or **tooltip**).
## How it works
Copy is a small JavaScript plugin that:
- listens for **Data API** triggers (`data-bs-toggle="copy"`)
- reads the text to copy from:
- `data-bs-copy-text` **or**
- `data-bs-copy-target` (input/textarea value, otherwise `textContent`)
- writes to clipboard via:
- `navigator.clipboard.writeText()` (preferred)
- fallback: `document.execCommand('copy')`
- shows feedback via:
- **swap**: toggles `[data-bs-copy-default]` / `[data-bs-copy-success]`
- **tooltip**: uses **Bootstrap Tooltip** if available
## Quick start
### 1) Include JavaScript
Copy is included in Tablers JavaScript bundle. Make sure you load:
```html
<script src="/dist/js/tabler.js"></script>
```
### 2) Add a trigger
{% capture html -%}
<button
type="button"
class="btn"
data-bs-toggle="copy"
data-bs-copy-text="INVITE-123"
>
<span data-bs-copy-default>Copy</span>
<span data-bs-copy-success hidden>Copied</span>
</button>
{%- endcapture -%}
{% include "docs/example.html" html=html centered %}
## Usage
### Via data attributes (recommended)
Copy is enabled through the Data API:
- add `data-bs-toggle="copy"` to the trigger element
- add `data-bs-copy-text` or `data-bs-copy-target`
{% capture html -%}
<div class="row g-2 align-items-center" style="max-width: 28rem;">
<div class="col-auto">
<button
type="button"
class="btn"
data-bs-toggle="copy"
data-bs-copy-target="#invite"
>
<span data-bs-copy-default>Copy</span>
<span data-bs-copy-success hidden>Copied</span>
</button>
</div>
<div class="col">
<input id="invite" class="form-control" value="INVITE-123" readonly>
</div>
</div>
{%- endcapture -%}
{% include "docs/example.html" html=html centered %}
### Via JavaScript
```js
import { Clipboard as Copy } from '@tabler/core'
const el = document.querySelector('[data-bs-toggle="copy"]')
const instance = Copy.getOrCreateInstance(el)
instance.copy()
```
## Markup
### Swap feedback (default)
The plugin looks for these optional elements **inside** the trigger:
- `[data-bs-copy-default]` — visible by default
- `[data-bs-copy-success]` — shown after copy; should start with `hidden`
{% capture html -%}
<button type="button" class="btn" data-bs-toggle="copy" data-bs-copy-text="Hello">
<span data-bs-copy-default>Copy</span>
<span data-bs-copy-success hidden>Copied</span>
</button>
{%- endcapture -%}
{% include "docs/example.html" html=html centered %}
### Tooltip feedback
Tooltip feedback does **not** require the two spans. It uses Bootstrap Tooltip if present:
{% capture html -%}
<div class="row g-2 align-items-center" style="max-width: 28rem;">
<div class="col">
<input id="token" class="form-control" value="sk_live_123" readonly>
</div>
<div class="col-auto">
<button
type="button"
class="btn"
data-bs-toggle="copy"
data-bs-copy-target="#token"
data-bs-copy-feedback="tooltip"
data-bs-copy-tooltip="Copied!"
>
Copy token
</button>
</div>
</div>
{%- endcapture -%}
{% include "docs/example.html" html=html centered %}
> Tooltip mode requires Bootstrap Tooltip + Popper.
> If Tooltip isnt available, Copy falls back to **swap** behavior.
## Options
Options can be passed via data attributes (`data-bs-copy-*`) or JS configuration.
### Data attribute options
| Name | Type | Default | Description |
|---|---:|---:|---|
| `data-bs-copy-text` | string | — | Explicit text to copy (highest priority). |
| `data-bs-copy-target` | string (selector) | — | Element selector to copy from. For inputs/textarea it copies `value`; otherwise it copies `textContent`. |
| `data-bs-copy-timeout` | number (ms) | `1500` | Duration to show copied feedback before reset (if enabled). |
| `data-bs-copy-reset` | boolean | `true` | Whether to automatically revert from “Copied” back to default state. Use `"false"` to disable. |
| `data-bs-copy-extend` | boolean | `true` | If true, repeated successful copies extend the reset timer. |
| `data-bs-copy-recopy` | boolean | `true` | If false and currently “Copied”, subsequent activations only extend timer (if enabled) without writing to clipboard again. |
| `data-bs-copy-feedback` | `"swap" \| "tooltip"` | `"swap"` | Feedback mode. Tooltip requires Bootstrap Tooltip. |
| `data-bs-copy-tooltip` | string | `"Copied!"` | Tooltip text (tooltip mode only). |
| `data-bs-copy-tooltip-placement` | string | `"top"` | Tooltip placement (tooltip mode only). |
### JavaScript options
```js
import { Clipboard as Copy } from '@tabler/core'
Copy.getOrCreateInstance(el, {
timeout: 1200,
reset: true,
extend: true,
recopy: true,
feedback: 'swap', // or 'tooltip'
tooltip: 'Copied!',
tooltipPlacement: 'top',
})
```
## Methods
### `copy()`
Copies text and triggers feedback.
```js
import { Clipboard as Copy } from '@tabler/core'
const instance = Copy.getOrCreateInstance(el)
instance.copy()
```
### `reset()`
Resets UI back to the default state (useful when `data-bs-copy-reset="false"`).
```js
import { Clipboard as Copy } from '@tabler/core'
const instance = Copy.getOrCreateInstance(el)
instance.reset()
```
### `dispose()`
Destroys the instance and clears timers.
```js
import { Clipboard as Copy } from '@tabler/core'
const instance = Copy.getOrCreateInstance(el)
instance.dispose()
```
### `Copy.getInstance(element)`
Returns the existing instance for an element, or `null` if none exists.
```js
import { Clipboard as Copy } from '@tabler/core'
Copy.getInstance(el)
```
### `Copy.getOrCreateInstance(element, config?)`
Returns an existing instance or creates a new one.
```js
import { Clipboard as Copy } from '@tabler/core'
Copy.getOrCreateInstance(el)
```
## Events
Copy emits Bootstrap-style events on the trigger element.
| Event | Description | Cancelable |
|---|---|---|
| `copy.bs.copy` | Fired immediately before copying. Access `event.detail.text` and `event.detail.target`. | **Yes** (call `event.preventDefault()`) |
| `copied.bs.copy` | Fired after a successful copy. | No |
| `copyfail.bs.copy` | Fired when copying fails (no text / clipboard error). `event.detail.reason` describes the cause. | No |
| `reset.bs.copy` | Fired when the UI resets back to default. | No |
### Event example
```js
document.addEventListener('copy.bs.copy', (e) => {
if (!e.detail.text) e.preventDefault()
})
document.addEventListener('copied.bs.copy', (e) => {
console.log('Copied:', e.detail.text)
})
document.addEventListener('copyfail.bs.copy', (e) => {
console.warn('Copy failed:', e.detail.reason)
})
```
## Examples
### Copy from an input group
{% capture html -%}
<div class="input-group" style="max-width: 460px;">
<input id="invite-group" class="form-control" value="INVITE-123" readonly>
<button class="btn" type="button" data-bs-toggle="copy" data-bs-copy-target="#invite-group">
<span data-bs-copy-default>Copy</span>
<span data-bs-copy-success hidden>Copied</span>
</button>
</div>
{%- endcapture -%}
{% include "docs/example.html" html=html centered %}
### Copy a code snippet
{% capture html -%}
<div class="card" style="max-width: 720px;">
<div class="card-header d-flex justify-content-between align-items-center">
<strong>Install</strong>
<button type="button" class="btn btn-sm" data-bs-toggle="copy" data-bs-copy-target="#snippet">
<span data-bs-copy-default>Copy</span>
<span data-bs-copy-success hidden>Copied</span>
</button>
</div>
<div class="card-body">
<pre id="snippet" class="mb-0"><code>npm install @tabler/core</code></pre>
</div>
</div>
{%- endcapture -%}
{% include "docs/example.html" html=html centered %}
### Tooltip feedback
{% capture html -%}
<button
type="button"
class="btn"
data-bs-toggle="copy"
data-bs-copy-text="sk_live_123"
data-bs-copy-feedback="tooltip"
data-bs-copy-tooltip="Copied!"
data-bs-copy-timeout="900"
>
Copy token
</button>
{%- endcapture -%}
{% include "docs/example.html" html=html centered %}
## Accessibility
- Prefer using a `<button type="button">` for triggers.
- If you use an `<a>` as a trigger, set `role="button"` and ensure it is keyboard accessible.
- Consider adding `aria-live="polite"` to the trigger to announce changes for assistive tech.
- In swap mode, the plugin toggles `hidden` on the feedback elements; ensure the success text is meaningful (e.g. “Copied”).
## Browser support notes
- Clipboard API (`navigator.clipboard`) typically requires a **secure context** (HTTPS) and user interaction.
- Fallback (`document.execCommand('copy')`) may be inconsistent across older browsers and restricted environments.

View File

@@ -1,19 +1,16 @@
---
title: Countup
summary: A countup element is used to display numerical data in an interesting way and make the interface more interactive.
docs-libs: countup
description: Display numbers dynamically with countups.
---
The countup component is used to display numbers dynamically. It is a great way to make the interface more interactive and engaging. The countup component is a simple and easy way to animate numbers in your application.
To be able to use the countup in your application you will need to install the countup.js dependency with `npm install countup.js`.
The countup component is built into Tabler and works similar to Bootstrap components. It animates numbers dynamically, making the interface more interactive and engaging.
For more advanced features of countups, see the demo on the [countUp.js website](https://inorganik.github.io/countUp.js/).
## Basic usage
To create a countup, add `data-countup` to any HTML text tag and specify the number which is to be reached. The animation will be triggered as soon as the number enters the viewport.
The easiest way to use countup is through the Data API. Add the `data-countup` attribute to any HTML text element and specify the number which is to be reached. The animation will be triggered as soon as the number enters the viewport.
```html
<h1 data-countup>30000</h1>
@@ -120,3 +117,64 @@ Set the countup suffix using `suffix` and specifying the suffix you want to add,
<h1 data-countup='{"suffix":"‰"}'>300</h1>
{%- endcapture %}
{% include "docs/example.html" html=html vertical separated %}
## Usage
### Via data attributes
Add `data-countup` to any HTML text element to automatically initialize countup. You can pass options as JSON:
```html
<h1 data-countup>30000</h1>
<h1 data-countup='{"duration":4,"prefix":"$"}'>30000</h1>
```
### Via JavaScript
Initialize countup programmatically using the `Countup` class:
```javascript
import { Countup } from '@tabler/core'
// Get or create instance
const element = document.querySelector('[data-countup]')
const countup = Countup.getOrCreateInstance(element)
countup.init()
```
### Methods
| Method | Description |
| --- | --- |
| `init()` | Initialize countup on the element. |
| `update()` | Update countup when the target value changes programmatically. |
| `dispose()` | Destroy countup instance. |
| `getInstance(element)` | *Static* method which allows you to get the countup instance associated with a DOM element. |
| `getOrCreateInstance(element)` | *Static* method which allows you to get the countup instance associated with a DOM element, or create a new one in case it wasn't initialized. |
#### Example: Update after value change
```javascript
import { Countup } from '@tabler/core'
const element = document.querySelector('[data-countup]')
const countup = Countup.getOrCreateInstance(element)
countup.init()
// Later, when the target value changes programmatically
element.innerHTML = '50000'
countup.update()
```
#### Example: Get existing instance
```javascript
import { Countup } from '@tabler/core'
const element = document.querySelector('[data-countup]')
const countup = Countup.getInstance(element)
if (countup) {
countup.update()
}
```

View File

@@ -5,36 +5,90 @@ banner: icons
description: Transition between two icons smoothly.
---
## Default markup
The switch-icon component is built into Tabler and works similar to Bootstrap components. It automatically toggles between two icons when clicked.
The icon transition is triggered by adding an `.active` class to the `switch-icon` component.
## Structure
{% capture html -%}
The switch-icon component requires a specific HTML structure:
```html
<button class="switch-icon" data-bs-toggle="switch-icon">
<span class="switch-icon-a text-secondary">
{% include "ui/icon.html" icon="heart" %}
<!-- Icon code here -->
</span>
<span class="switch-icon-b text-red">
{% include "ui/icon.html" icon="heart" type="filled" %}
<!-- Icon code here -->
</span>
</button>
```
**Required elements:**
- `button` element with `switch-icon` class and `data-bs-toggle="switch-icon"` attribute
- `switch-icon-a` span containing the first icon (shown by default)
- `switch-icon-b` span containing the second icon (shown when `.active` class is added to the button)
The transition between icons is triggered by adding or removing the `.active` class on the button element.
## Basic usage
The easiest way to use switch-icon is through the Data API. Add the `data-bs-toggle="switch-icon"` attribute to a button element:
{% capture html -%}
{% include "ui/switch-icon.html" icon="heart" icon-b-color="red" %}
{%- endcapture %}
{% include "docs/example.html" html=html %}
{% include "docs/example.html" html=html centered %}
## Switch animations
You can also add a fancy animation to add variety to your button. See demo below:
You can also add a fancy animation to add variety to your button. Available animation variants:
{% capture html -%}
<button class="switch-icon" data-bs-toggle="switch-icon">
| Variant | Description |
| --- | --- |
| `fade` | Fade transition between icons. |
| `scale` | Scale animation when switching icons. |
| `flip` | Flip animation when switching icons. |
| `slide-up` | Slide animation from bottom to top. |
| `slide-down` | Slide animation from top to bottom. |
| `slide-left` | Slide animation from right to left. |
| `slide-right` | Slide animation from left to right. |
| `slide-start` | Slide animation from end to start (RTL-aware). |
| `slide-end` | Slide animation from start to end (RTL-aware). |
To add an animation, add the corresponding class to the button element. For example, to use the fade animation, add `switch-icon-fade` class:
```html
<button class="switch-icon switch-icon-fade" data-bs-toggle="switch-icon">
<span class="switch-icon-a text-secondary">
{% include "ui/icon.html" icon="circle" %}
<!-- Icon code here -->
</span>
<span class="switch-icon-b text-primary">
{% include "ui/icon.html" icon="circle" type="filled" %}
<span class="switch-icon-b text-red">
<!-- Icon code here -->
</span>
</button>
<button class="switch-icon switch-icon-fade" data-bs-toggle="switch-icon">
```
See demo below:
{% capture html -%}
{% include "ui/switch-icon.html" icon="circle" icon-b-color="primary" %}
{% include "ui/switch-icon.html" icon="heart" variant="fade" icon-b-color="red" %}
{% include "ui/switch-icon.html" icon="star" variant="scale" icon-b-color="yellow" %}
{% include "ui/switch-icon.html" icon="thumb-up" variant="flip" icon-b-color="facebook" %}
{% include "ui/switch-icon.html" icon="brand-twitter" icon-b="brand-twitter" variant="slide-up" icon-b-color="twitter" %}
{% include "ui/switch-icon.html" icon="check" icon-b="x" variant="slide-left" icon-b-color="red" %}
{% include "ui/switch-icon.html" icon="arrow-up" icon-b="arrow-down" variant="slide-down" icon-b-color="secondary" %}
{% include "ui/switch-icon.html" icon="car" icon-b="scooter" variant="slide-end" icon-b-color="secondary" %}
{%- endcapture %}
{% include "docs/example.html" html=html centered %}
## Usage
### Via data attributes
Add `data-bs-toggle="switch-icon"` to a button element to automatically initialize switch-icon:
```html
<button class="switch-icon" data-bs-toggle="switch-icon">
<span class="switch-icon-a text-secondary">
{% include "ui/icon.html" icon="heart" %}
</span>
@@ -42,178 +96,77 @@ You can also add a fancy animation to add variety to your button. See demo below
{% include "ui/icon.html" icon="heart" type="filled" %}
</span>
</button>
<button class="switch-icon switch-icon-scale" data-bs-toggle="switch-icon">
<span class="switch-icon-a text-secondary">
{% include "ui/icon.html" icon="star" %}
</span>
<span class="switch-icon-b text-yellow">
{% include "ui/icon.html" icon="star" type="filled" %}
</span>
</button>
<button class="switch-icon switch-icon-flip" data-bs-toggle="switch-icon">
<span class="switch-icon-a text-secondary">
{% include "ui/icon.html" icon="thumb-up" %}
</span>
<span class="switch-icon-b text-facebook">
{% include "ui/icon.html" icon="thumb-up" type="filled" %}
</span>
</button>
<button class="switch-icon switch-icon-slide-up" data-bs-toggle="switch-icon">
<span class="switch-icon-a text-secondary">
<svg
xmlns="http://www.w3.org/2000/svg"
class="icon"
width="24"
height="24"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path
d="M22 4.01c-1 .49 -1.98 .689 -3 .99c-1.121 -1.265 -2.783 -1.335 -4.38 -.737s-2.643 2.06 -2.62 3.737v1c-3.245 .083 -6.135 -1.395 -8 -4c0 0 -4.182 7.433 4 11c-1.872 1.247 -3.739 2.088 -6 2c3.308 1.803 6.913 2.423 10.034 1.517c3.58 -1.04 6.522 -3.723 7.651 -7.742a13.84 13.84 0 0 0 .497 -3.753c-.002 -.249 1.51 -2.772 1.818 -4.013z"
/>
</svg>
</span>
<span class="switch-icon-b text-twitter">
<svg
xmlns="http://www.w3.org/2000/svg"
class="icon"
width="24"
height="24"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path
d="M22 4.01c-1 .49 -1.98 .689 -3 .99c-1.121 -1.265 -2.783 -1.335 -4.38 -.737s-2.643 2.06 -2.62 3.737v1c-3.245 .083 -6.135 -1.395 -8 -4c0 0 -4.182 7.433 4 11c-1.872 1.247 -3.739 2.088 -6 2c3.308 1.803 6.913 2.423 10.034 1.517c3.58 -1.04 6.522 -3.723 7.651 -7.742a13.84 13.84 0 0 0 .497 -3.753c-.002 -.249 1.51 -2.772 1.818 -4.013z"
/>
</svg>
</span>
</button>
<button class="switch-icon switch-icon-slide-left" data-bs-toggle="switch-icon">
<span class="switch-icon-a text-secondary">
<svg
xmlns="http://www.w3.org/2000/svg"
class="icon"
width="24"
height="24"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M5 12l5 5l10 -10" />
</svg>
</span>
<span class="switch-icon-b text-red">
<svg
xmlns="http://www.w3.org/2000/svg"
class="icon"
width="24"
height="24"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</span>
</button>
<button class="switch-icon switch-icon-slide-down" data-bs-toggle="switch-icon">
<span class="switch-icon-a text-secondary">
<svg
xmlns="http://www.w3.org/2000/svg"
class="icon"
width="24"
height="24"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="18" y1="13" x2="12" y2="19" />
<line x1="6" y1="13" x2="12" y2="19" />
</svg>
</span>
<span class="switch-icon-b text-secondary">
<svg
xmlns="http://www.w3.org/2000/svg"
class="icon"
width="24"
height="24"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="18" y1="11" x2="12" y2="5" />
<line x1="6" y1="11" x2="12" y2="5" />
</svg>
</span>
</button>
<button class="switch-icon switch-icon-slide-end" data-bs-toggle="switch-icon">
<span class="switch-icon-a text-secondary">
<svg
xmlns="http://www.w3.org/2000/svg"
class="icon"
width="24"
height="24"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<circle cx="7" cy="17" r="2" />
<circle cx="17" cy="17" r="2" />
<path d="M5 17h-2v-6l2 -5h9l4 5h1a2 2 0 0 1 2 2v4h-2m-4 0h-6m-6 -6h15m-6 0v-5" />
</svg>
</span>
<span class="switch-icon-b text-secondary">
<svg
xmlns="http://www.w3.org/2000/svg"
class="icon"
width="24"
height="24"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<circle cx="18" cy="17" r="2" />
<circle cx="6" cy="17" r="2" />
<path d="M8 17h5a6 6 0 0 1 5 -5v-5a2 2 0 0 0 -2 -2h-1" />
</svg>
</span>
</button>
{%- endcapture %}
{% include "docs/example.html" html=html %}
```
### Via JavaScript
Initialize switch-icon programmatically using the `SwitchIcon` class:
```javascript
import { SwitchIcon } from '@tabler/core'
// Get or create instance
const button = document.querySelector('.switch-icon')
const switchIcon = SwitchIcon.getOrCreateInstance(button)
switchIcon.init()
```
### Methods
| Method | Description |
| --- | --- |
| `init()` | Initialize switch-icon on the element. |
| `toggle()` | Toggle active state of the switch-icon. |
| `show()` | Activate the switch-icon (add `.active` class). |
| `hide()` | Deactivate the switch-icon (remove `.active` class). |
| `isActive()` | Check if switch-icon is currently active. |
| `dispose()` | Destroy switch-icon instance. |
| `getInstance(element)` | *Static* method which allows you to get the switch-icon instance associated with a DOM element. |
| `getOrCreateInstance(element)` | *Static* method which allows you to get the switch-icon instance associated with a DOM element, or create a new one in case it wasn't initialized. |
#### Example: Toggle programmatically
```javascript
import { SwitchIcon } from '@tabler/core'
const button = document.querySelector('.switch-icon')
const switchIcon = SwitchIcon.getOrCreateInstance(button)
switchIcon.init()
// Later, toggle programmatically
switchIcon.toggle()
```
#### Example: Show/hide programmatically
```javascript
import { SwitchIcon } from '@tabler/core'
const button = document.querySelector('.switch-icon')
const switchIcon = SwitchIcon.getOrCreateInstance(button)
switchIcon.init()
// Activate
switchIcon.show()
// Deactivate
switchIcon.hide()
// Check state
if (switchIcon.isActive()) {
console.log('Switch icon is active')
}
```
#### Example: Get existing instance
```javascript
import { SwitchIcon } from '@tabler/core'
const button = document.querySelector('.switch-icon')
const switchIcon = SwitchIcon.getInstance(button)
if (switchIcon) {
switchIcon.toggle()
}
```

View File

@@ -2,34 +2,15 @@
title: Input mask
summary: An input mask is used to clarify the input format required in a given field and is helpful for users, removing confusion and reducing the number of validation errors.
description: Clarify input formats for users.
docs-libs: imask
---
## Installation
The input mask component is built into Tabler and works similar to Bootstrap components. It automatically applies input masks to form fields to clarify the required format.
To be able to use the input mask in your application you will need to install the imask dependency. You can do this by running the following command:
For more advanced features of input masks, see the [IMask documentation](https://imask.js.org/guide.html#masked-input).
{% include "docs/tabs-package.html" name="imask" %}
## Basic usage
And import or require:
```javascript
import IMask from 'imask';
```
You can also use the CDN link to include the script in your project:
```html
<script src="https://cdn.jsdelivr.net/npm/imask"></script>
```
If you struggle with the installation, you can find more information in the [IMask documentation](https://imask.js.org/guide.html#installation).
## Default markup
Use an input mask in the fields where users have to enter their phone number, to make the formatting rules clear and help them avoid confusion.
To create an input mask, add the `data-mask` attribute to the input element:
The easiest way to use input mask is through the Data API. Add the `data-mask` attribute to the input element:
```html
<input
@@ -60,4 +41,89 @@ Look at the example below to see how the input mask works:
## More examples
If you need more examples of input masks, you can find them in the [IMask documentation](https://imask.js.org/guide.html#masked-input).
If you need more examples of input masks, you can find them in the [IMask documentation](https://imask.js.org/guide.html#masked-input).
## Usage
### Via data attributes
Add `data-mask` to an input element to automatically initialize input mask. You can also add `data-mask-visible="true"` to show the mask placeholder:
```html
<input
type="text"
class="form-control"
data-mask="(00) 0000-0000"
data-mask-visible="true"
placeholder="(00) 0000-0000"
autocomplete="off"
/>
```
### Via JavaScript
Initialize input mask programmatically using the `InputMask` class:
```javascript
import { InputMask } from '@tabler/core'
// Get or create instance
const input = document.querySelector('[data-mask]')
const inputMask = InputMask.getOrCreateInstance(input)
inputMask.init()
```
### Methods
| Method | Description |
| --- | --- |
| `init()` | Initialize input mask on the element. |
| `update(mask?, options?)` | Update input mask when mask or options change programmatically. |
| `getValue()` | Get the current masked value. |
| `getUnmaskedValue()` | Get the current unmasked value. |
| `dispose()` | Destroy input mask instance. |
| `getInstance(element)` | *Static* method which allows you to get the input mask instance associated with a DOM element. |
| `getOrCreateInstance(element)` | *Static* method which allows you to get the input mask instance associated with a DOM element, or create a new one in case it wasn't initialized. |
#### Example: Update mask programmatically
```javascript
import { InputMask } from '@tabler/core'
const input = document.querySelector('[data-mask]')
const inputMask = InputMask.getOrCreateInstance(input)
inputMask.init()
// Later, update the mask
inputMask.update('000-000-0000')
```
#### Example: Get values
```javascript
import { InputMask } from '@tabler/core'
const input = document.querySelector('[data-mask]')
const inputMask = InputMask.getOrCreateInstance(input)
inputMask.init()
// Get masked value (e.g., "(12) 3456-7890")
const maskedValue = inputMask.getValue()
// Get unmasked value (e.g., "1234567890")
const unmaskedValue = inputMask.getUnmaskedValue()
```
#### Example: Get existing instance
```javascript
import { InputMask } from '@tabler/core'
const input = document.querySelector('[data-mask]')
const inputMask = InputMask.getInstance(input)
if (inputMask) {
const value = inputMask.getValue()
console.log('Masked value:', value)
}
```

View File

@@ -0,0 +1,354 @@
---
title: Theme Customization
summary: Tabler provides multiple ways to customize themes including HTML attributes, CSS classes, CSS custom properties, and JavaScript API. Learn how to control color mode, primary colors, fonts, base colors, and border radius to create a personalized user experience.
description: Customize Tabler themes using attributes, CSS, and JavaScript.
bootstrapLink: customize/color-modes/
---
Tabler offers flexible theme customization through multiple methods. You can control the color mode (light/dark), primary color, font family, base color scheme, and border radius using HTML attributes, CSS classes, CSS custom properties, or the JavaScript API.
## Color Mode (Light/Dark)
### Using HTML Attributes
The most common way to control the color mode is by setting the `data-bs-theme` attribute on the `<html>` element:
```html
<html data-bs-theme="dark">
<!-- Dark mode content -->
</html>
```
Available values:
- `light` - Light color mode (default)
- `dark` - Dark color mode
### Using JavaScript
You can dynamically change the theme using JavaScript:
```javascript
// Set dark mode
document.documentElement.setAttribute('data-bs-theme', 'dark')
// Set light mode
document.documentElement.setAttribute('data-bs-theme', 'light')
// Remove attribute to use default (light)
document.documentElement.removeAttribute('data-bs-theme')
```
### Using URL Parameters
Tabler automatically reads theme settings from URL parameters. You can link to pages with specific themes:
```html
<a href="?theme=dark">Switch to Dark Mode</a>
<a href="?theme=light">Switch to Light Mode</a>
```
### Using CSS Classes
You can also use CSS classes for theme control:
```html
<html class="theme-dark">
<!-- Dark mode content -->
</html>
```
## Primary Color
### Using HTML Attributes
Set the primary color using the `data-bs-theme-primary` attribute:
```html
<html data-bs-theme-primary="red">
<!-- Red primary color -->
</html>
```
Available values include: `blue`, `azure`, `indigo`, `purple`, `pink`, `red`, `orange`, `yellow`, `lime`, `green`, `teal`, `cyan`, and more.
### Using JavaScript
```javascript
document.documentElement.setAttribute('data-bs-theme-primary', 'red')
document.documentElement.setAttribute('data-bs-theme-primary', 'green')
document.documentElement.setAttribute('data-bs-theme-primary', 'purple')
```
### Using URL Parameters
```html
<a href="?theme-primary=red">Red Theme</a>
<a href="?theme-primary=green">Green Theme</a>
```
## Base Color Scheme
### Using HTML Attributes
Control the gray color scheme using `data-bs-theme-base`:
```html
<html data-bs-theme-base="slate">
<!-- Slate gray scheme -->
</html>
```
Available values: `slate`, `gray`, `zinc`, `neutral`, `stone`
### Using JavaScript
```javascript
document.documentElement.setAttribute('data-bs-theme-base', 'slate')
document.documentElement.setAttribute('data-bs-theme-base', 'gray')
document.documentElement.setAttribute('data-bs-theme-base', 'zinc')
```
## Font Family
### Using HTML Attributes
Set the font family with `data-bs-theme-font`:
```html
<html data-bs-theme-font="monospace">
<!-- Monospace font -->
</html>
```
Available values: `sans-serif`, `serif`, `monospace`, `comic`
### Using JavaScript
```javascript
document.documentElement.setAttribute('data-bs-theme-font', 'monospace')
document.documentElement.setAttribute('data-bs-theme-font', 'serif')
```
## Border Radius
### Using HTML Attributes
Control border radius factor with `data-bs-theme-radius`:
```html
<html data-bs-theme-radius="2">
<!-- Larger border radius -->
</html>
```
Available values: `0`, `0.5`, `1`, `1.5`, `2`
### Using JavaScript
```javascript
document.documentElement.setAttribute('data-bs-theme-radius', '0')
document.documentElement.setAttribute('data-bs-theme-radius', '1')
document.documentElement.setAttribute('data-bs-theme-radius', '2')
```
## Combining Multiple Settings
You can combine multiple theme attributes on the same element:
```html
<html
data-bs-theme="dark"
data-bs-theme-primary="red"
data-bs-theme-base="slate"
data-bs-theme-font="monospace"
data-bs-theme-radius="2">
<!-- Custom themed page -->
</html>
```
Or using JavaScript:
```javascript
const html = document.documentElement
html.setAttribute('data-bs-theme', 'dark')
html.setAttribute('data-bs-theme-primary', 'red')
html.setAttribute('data-bs-theme-base', 'slate')
html.setAttribute('data-bs-theme-font', 'monospace')
html.setAttribute('data-bs-theme-radius', '2')
```
## Using CSS Custom Properties
Tabler uses CSS custom properties (variables) that you can override directly:
```html
<style>
:root {
--tblr-primary: #d63939; /* Red */
--tblr-body-font-family: 'Courier New', monospace;
--tblr-border-radius-scale: 2;
}
</style>
```
You can also target specific theme modes:
```css
[data-bs-theme='dark'] {
--tblr-primary: #ff6b6b;
--tblr-body-bg: #1a1a1a;
}
```
## Using JavaScript API
Tabler provides a convenient JavaScript API for theme manipulation. The API automatically handles DOM updates, localStorage persistence, and URL updates.
### Basic Usage
```html
<script src="{{ cdnUrl }}/dist/js/tabler.min.js"></script>
<script>
Tabler.setTheme('dark')
Tabler.setPrimary('red')
Tabler.setBase('slate')
Tabler.setFont('monospace')
Tabler.setRadius('2')
</script>
```
### Available Methods
- `Tabler.setTheme(value)` - Set color mode (`light` or `dark`)
- `Tabler.setPrimary(value)` - Set primary color
- `Tabler.setBase(value)` - Set base color scheme
- `Tabler.setFont(value)` - Set font family
- `Tabler.setRadius(value)` - Set border radius factor
- `Tabler.reset()` - Reset all settings to defaults
- `Tabler.getConfig()` - Get current theme configuration
### Example: Theme Switcher
```html
<button onclick="Tabler.setTheme('dark')">Dark Mode</button>
<button onclick="Tabler.setTheme('light')">Light Mode</button>
<button onclick="Tabler.setPrimary('red')">Red Theme</button>
<button onclick="Tabler.reset()">Reset</button>
```
## Persistence with localStorage
Tabler automatically saves theme settings to `localStorage` when using the JavaScript API. Settings are stored with keys like:
- `tabler-theme`
- `tabler-theme-primary`
- `tabler-theme-base`
- `tabler-theme-font`
- `tabler-theme-radius`
You can manually read and write to localStorage:
```javascript
// Save theme setting
localStorage.setItem('tabler-theme', 'dark')
// Read theme setting
const theme = localStorage.getItem('tabler-theme') || 'light'
document.documentElement.setAttribute('data-bs-theme', theme)
```
## Scoped Themes
You can apply themes to specific elements instead of the entire page:
```html
<div data-bs-theme="dark">
<div class="card">
<!-- This card uses dark theme -->
</div>
</div>
<div data-bs-theme="light">
<div class="card">
<!-- This card uses light theme -->
</div>
</div>
```
## Complete Example
Here's a complete example showing different ways to control themes:
```html
<!doctype html>
<html lang="en" data-bs-theme="light">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Theme Customization</title>
<link rel="stylesheet" href="{{ cdnUrl }}/dist/css/tabler.min.css" />
</head>
<body>
<div class="container-xl">
<div class="page-header">
<h1 class="page-title">Theme Customization</h1>
</div>
<div class="card">
<div class="card-body">
<h3>Method 1: Direct Attribute Manipulation</h3>
<button
class="btn"
onclick="document.documentElement.setAttribute('data-bs-theme', 'dark')">
Set Dark Mode
</button>
<button
class="btn"
onclick="document.documentElement.setAttribute('data-bs-theme', 'light')">
Set Light Mode
</button>
<h3 class="mt-4">Method 2: Using Tabler API</h3>
<button class="btn" onclick="Tabler.setTheme('dark')">
Dark Mode (API)
</button>
<button class="btn" onclick="Tabler.setPrimary('red')">
Red Primary (API)
</button>
<button class="btn" onclick="Tabler.reset()">
Reset (API)
</button>
<h3 class="mt-4">Method 3: URL Parameters</h3>
<a href="?theme=dark&theme-primary=red" class="btn">
Dark + Red via URL
</a>
</div>
</div>
</div>
<script src="{{ cdnUrl }}/dist/js/tabler.min.js"></script>
</body>
</html>
```
## Best Practices
1. **Initialize on Load**: Apply saved theme settings when the page loads
2. **User Preferences**: Consider saving user preferences to your backend
3. **Smooth Transitions**: Add CSS transitions for smoother theme changes
4. **Accessibility**: Ensure theme changes don't break accessibility features
5. **Consistency**: Use the same method (API, attributes, or CSS) throughout your application
## Bootstrap Documentation
Tabler's theme system is built on top of Bootstrap's color mode and CSS variables system. For more information, see:
- [Bootstrap Color Modes](https://getbootstrap.com/docs/5.3/customize/color-modes/) - Learn about Bootstrap's dark mode implementation
- [Bootstrap CSS Variables](https://getbootstrap.com/docs/5.3/customize/css-variables/) - Understand how CSS custom properties work in Bootstrap
- [Bootstrap Theming](https://getbootstrap.com/docs/5.3/customize/overview/) - General theming and customization guide
## Related Documentation
- [Customize Tabler](/ui/getting-started/customize) - Learn about CSS customization
- [Installation](/ui/getting-started/installation) - Set up Tabler in your project
- [Colors](/ui/base/colors) - Understand Tabler's color system

View File

@@ -8,6 +8,7 @@
"clean": "turbo clean",
"bundlewatch": "turbo bundlewatch",
"type-check": "turbo type-check",
"test": "turbo test",
"version": "changeset version",
"publish": "changeset publish",
"reformat-md": "tsx .build/reformat-mdx.ts",

1132
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,7 @@
title: Form elements
page-header: Form elements
page-menu: form.elements
page-libs: [nouislider, autosize, tabler-flags, tabler-payments, litepicker, tom-select, imask]
page-libs: [nouislider, tabler-flags, tabler-payments, litepicker, tom-select, imask]
layout: default
permalink: form-elements.html
---

View File

@@ -8,9 +8,9 @@
{% unless include.hide-code %}
<div class="position-relative">
<a class="btn btn-icon btn-dark position-absolute m-2 top-0 end-0 z-3" data-clipboard-text="{{ include.html | escape_attribute }}">
{% include "ui/icon.html" icon="clipboard" %}
{% include "ui/icon.html" icon="check" class="d-none" %}
<a class="btn btn-icon btn-dark position-absolute m-2 top-0 end-0 z-3" data-bs-toggle="copy" data-bs-copy-text="{{ include.html | escape_attribute }}">
{% include "ui/icon.html" icon="clipboard" attributes="data-bs-copy-default" %}
{% include "ui/icon.html" icon="check" attributes="data-bs-copy-success" class="d-none" %}
</a>
```html

View File

@@ -108,7 +108,7 @@
<script>
/*
This script handles the theme settings offcanvas functionality
It saves the selected settings to localStorage and applies them to the document
It uses the Tabler API to save the selected settings and apply them to the document
It also updates the URL with the selected settings as query parameters
It also has a reset button to clear all settings and revert to default
@@ -127,9 +127,22 @@
var form = document.getElementById("offcanvas-settings")
var resetButton = document.getElementById("reset-changes")
// Wait for Tabler to be available
var waitForTabler = function (callback) {
if (window.Tabler) {
callback()
} else {
setTimeout(function () {
waitForTabler(callback)
}, 50)
}
}
var checkItems = function () {
var config = window.Tabler ? window.Tabler.getConfig() : themeConfig
for (var key in themeConfig) {
var value = window.localStorage["tabler-" + key] || themeConfig[key]
var value = config[key] || themeConfig[key]
if (!!value) {
var radios = form.querySelectorAll(`[name="${key}"]`)
@@ -143,36 +156,35 @@
}
}
form.addEventListener("change", function (event) {
var target = event.target,
name = target.name,
value = target.value
waitForTabler(function () {
form.addEventListener("change", function (event) {
var target = event.target,
name = target.name,
value = target.value
for (var key in themeConfig) {
if (name === key) {
document.documentElement.setAttribute("data-bs-" + key, value)
window.localStorage.setItem("tabler-" + key, value)
url.searchParams.set(key, value)
if (name === "theme") {
window.Tabler.setTheme(value)
} else if (name === "theme-primary") {
window.Tabler.setPrimary(value)
} else if (name === "theme-base") {
window.Tabler.setBase(value)
} else if (name === "theme-font") {
window.Tabler.setFont(value)
} else if (name === "theme-radius") {
window.Tabler.setRadius(value)
}
}
window.history.pushState({}, "", url)
})
url.searchParams.set(name, value)
window.history.pushState({}, "", url)
})
resetButton.addEventListener("click", function () {
for (var key in themeConfig) {
var value = themeConfig[key]
document.documentElement.removeAttribute("data-bs-" + key)
window.localStorage.removeItem("tabler-" + key)
url.searchParams.delete(key)
}
resetButton.addEventListener("click", function () {
window.Tabler.reset()
checkItems()
})
checkItems()
window.history.pushState({}, "", url)
})
checkItems()
})
</script>
{% endcapture_script %}

View File

@@ -1 +1 @@
<input type="text" name="input-{{ include.name | default: 'mask' }}" class="form-control" data-mask="{{ include.mask | default: '00/00/0000' }}"{% if include.visible %} data-mask-visible="true"{% endif %}{% if include.placeholder %} placeholder="{{ include.placeholder }}"{% else %} placeholder="{{ include.mask }}"{% endif %}{% if include.reverse %} data-mask-reverse="true"{% endif %}autocomplete="off"/>
<input type="text" name="input-{{ include.name | default: 'mask' }}" class="form-control" data-mask="{{ include.mask | default: '00/00/0000' }}"{% if include.visible %} data-mask-visible="true"{% endif %}{% if include.placeholder %} placeholder="{{ include.placeholder }}"{% else %} placeholder="{{ include.mask }}"{% endif %}{% if include.reverse %} data-mask-reverse="true"{% endif %} autocomplete="off"/>

View File

@@ -1 +1 @@
<textarea class="form-control{% if include.class %} {{ include.class }}{% endif %}" data-bs-toggle="autosize" placeholder="{{ include.placeholder | default: 'Type something…' }}"{% if include.rows %} rows="{{ include.rows }}"{% endif %}></textarea>
<textarea class="form-control{% if include.class %} {{ include.class }}{% endif %}" data-bs-autosize placeholder="{{ include.placeholder | default: 'Type something…' }}"{% if include.rows %} rows="{{ include.rows }}"{% endif %}></textarea>

View File

@@ -2,6 +2,7 @@
{%- assign icon-type = include.type | default: "outline" -%}
{%- assign replace-to = "icon" -%}
{%- assign icon-attributes = include.attributes | default: '' | strip -%}
{%- if include.class -%}
{%- assign replace-to = replace-to | append: ' ' | append: include.class -%}
@@ -22,10 +23,15 @@
{%- assign replace-to = 'class="' | append: replace-to | append: '"' -%}
{%- if site.useIconfont -%}
<i class="icon ti ti-{{ icon-name }}{% if include.color %} {{ include.color }}{% endif %}{% if include.class %} {{ include.class }}{% endif %}"></i>
<i{% if icon-attributes %} {{ icon-attributes }}{% endif %} class="icon ti ti-{{ icon-name }}{% if include.color %} {{ include.color }}{% endif %}{% if include.class %} {{ include.class }}{% endif %}"></i>
{%- elsif icons[icon-name] -%}
<!-- Download SVG icon from http://tabler.io/icons/icon/{{ icon-name }} -->
{% assign svg-icon = icons[icon-name].svg[icon-type] | default: '' -%}
{%- assign svg-icon = svg-icon | replace: '<path stroke="none" d="M0 0h24v24H0z" fill="none"/>', '' -%}
{{ svg-icon | replace_regex: 'class=\"[^"]+\"', replace-to | replace: 'class="', 'aria-hidden="true" focusable="false" class="' }}
{%- assign svg-attributes = 'aria-hidden="true" focusable="false"' -%}
{%- if icon-attributes -%}
{%- assign svg-attributes = svg-attributes | append: ' ' | append: icon-attributes -%}
{%- endif -%}
{%- assign svg-attributes = svg-attributes | append: ' class="' -%}
{{ svg-icon | replace_regex: 'class=\"[^"]+\"', replace-to | replace: 'class="', svg-attributes }}
{%- endif -%}

View File

@@ -1,13 +1,13 @@
{% assign icon = include.icon | default: 'heart' %}
{% assign icon-b = include.icon-b | default: icon | default: 'heart' %}
{% assign icon-a-color = include.icon-a-color | default: 'muted' %}
{% assign icon-b-color = include.icon-b-color | default: 'red' %}
{% assign icon = include.icon | default: 'heart' -%}
{% assign icon-b = include.icon-b | default: icon | default: 'heart' -%}
{% assign icon-a-color = include.icon-a-color | default: 'secondary' -%}
{% assign icon-b-color = include.icon-b-color | default: 'red' -%}
{% if icon == 'star' or icon == 'heart' %}
{% assign icon-b-class = 'icon-filled' %}
{% else %}
{% assign icon-b-class = include.icon-b-class %}
{% endif %}
{% if icon == 'star' or icon == 'heart' or icon == 'circle' or icon == 'brand-twitter' -%}
{% assign icon-b-class = 'icon-filled' -%}
{% else -%}
{% assign icon-b-class = include.icon-b-class -%}
{% endif -%}
<button class="switch-icon{% if include.variant %} switch-icon-{{ include.variant }}{% endif %}{% if include.active %} active{% endif %}" data-bs-toggle="switch-icon">
<span class="switch-icon-a text-{{ icon-a-color }}">

View File

@@ -64,13 +64,12 @@
rel="stylesheet" />
{% endfor %}
{% if docs-libs -%}
{% for lib in libs.css -%}
{% for lib in libs -%}
{% if docs-libs contains lib[0] -%}
{% for file in lib[1] -%}
{% for file in lib[1].css -%}
<link
href="{% if file contains 'http://' or file contains 'https://' %}{{ file }}{% else %}{{ page | relative }}/libs/{% if environment != 'development' %}{{ file | replace: '@', '' }}{% else %}{{ file }}{% endif %}{% if environment != 'development' %}?{{ 'now' | date: '%s' }}{% endif %}{% endif %}"
href="{% if file contains 'http://' or file contains 'https://' %}{{ file }}{% else %}/dist/libs/{{ lib[1].npm }}/{% if environment != 'development' %}{{ file | replace: '@', '' }}{% else %}{{ file }}{% endif %}{% if environment != 'development' %}?{{ 'now' | date: '%s' }}{% endif %}{% endif %}"
rel="stylesheet" />
{% endfor -%}
{% endif -%}
@@ -165,7 +164,7 @@
<!-- END PAGE BODY -->
{% for lib in libs -%}
{% if docs-libs contains lib[0] or libs.global-libs contains lib[0] or lib[0] == "clipboard" -%}
{% if docs-libs contains lib[0] or libs.global-libs contains lib[0] -%}
{% for file in lib[1].js -%}
<script
src="{% if file contains 'http://' or file contains 'https://' %}{{ file | replace: 'GOOGLE_MAPS_KEY', google-maps-key }}{% else %}/dist/libs/{{ lib[1].npm }}/{% if environment != 'development' %}{{ file | replace: '@', '' }}{% else %}{{ file }}{% endif %}{% if environment != 'development' %}?{{ 'now' | date: '%s' }}{% endif %}{% endif %}"
@@ -174,40 +173,6 @@
{% endif -%}
{% endfor -%}
<script>
document.addEventListener('DOMContentLoaded', function () {
const elements = document.querySelectorAll('[data-clipboard-text]');
elements.forEach(function (element) {
const clipboard = new ClipboardJS(element, {
text: function (trigger) {
return element.getAttribute('data-clipboard-text');
}
});
clipboard.on('success', function (e) {
e.clearSelection();
e.trigger.classList.add('btn-success');
e.trigger.classList.remove('btn-dark');
e.trigger.children[0].classList.add('d-none');
e.trigger.children[1].classList.remove('d-none');
setTimeout(function () {
e.trigger.classList.remove('btn-success');
e.trigger.classList.add('btn-dark');
e.trigger.children[0].classList.remove('d-none');
e.trigger.children[1].classList.add('d-none');
}, 2000);
});
clipboard.on('error', function (e) {
console.error('Error copying text: ', e);
});
});
})
</script>
<script src="/dist/js/tabler{% if environment != 'development' %}.min{% endif %}.js"></script>
<script src="/js/docs{% if environment != 'development' %}.min{% endif %}.js" defer></script>

View File

@@ -37,6 +37,12 @@
"^type-check"
],
"cache": true
},
"test": {
"outputs": [
"coverage/**"
],
"cache": true
}
}
}