Compare commits

...

38 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
codecalm
8e3cddb70f Remove Playwright configuration and related dependencies from the project, including visual regression tests and associated scripts. 2026-01-09 21:51:57 +01:00
Paweł Kuna
857988dd44 Migrate core JavaScript to TypeScript (#2582)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-09 21:49:22 +01:00
Paweł Kuna
a24d5cab13 Add markdown linting script (#2585) 2026-01-09 21:39:18 +01:00
codecalm
0c654c61f0 chore: update Node.js version to 22 in configuration files 2026-01-08 17:23:59 +01:00
codecalm
8e73f57140 docs: update browser support documentation to reflect minimum version requirements and clarify unsupported browsers 2026-01-08 16:50:48 +01:00
codecalm
f0fb9c66c0 chore: update browserslist configuration to support newer versions and remove outdated entries 2026-01-08 16:48:50 +01:00
dependabot[bot]
29d9d4b5df chore(deps): bump actions/cache from 4 to 5 (#2568)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-06 16:38:36 +01:00
Paweł Kuna
84c31d1383 chore: update dependencies and improve Eleventy configuration (#2581) 2026-01-06 16:12:56 +01:00
Paweł Kuna
41fd82b388 update icons to v3.36.1 (#2580) 2026-01-06 02:11:14 +01:00
codecalm
abac36c580 chore: update illustrations for dark and light themes 2026-01-06 01:58:46 +01:00
Paweł Kuna
301e77898c refactor: migrate rgba() to color-mix() and color-transparent() (#2579) 2026-01-06 01:54:34 +01:00
Paweł Kuna
a14425792b Update SCSS variables and button styles (#2559) 2026-01-06 00:32:01 +01:00
Paweł Kuna
48dbd1ed1b refactor: migrate build system from Rollup to Vite (#2578) 2026-01-06 00:26:42 +01:00
codecalm
ee8875deb6 fix: add aria-orientation attribute for vertical navigation in nav-segmented.html 2026-01-06 00:19:23 +01:00
codecalm
c0a93b8611 refactor: simplify SRI generation process and improve error handling in generate-sri.js 2026-01-05 23:32:16 +01:00
codecalm
42081245b4 fix: update input background variable to use form background color 2026-01-05 23:05:34 +01:00
codecalm
d56e1a2bac chore: add .env and sri.json to .gitignore 2026-01-03 01:43:34 +01:00
codecalm
c6e8879bb6 chore: remove unused sri.json file 2026-01-03 01:43:05 +01:00
codecalm
a811fdb662 chore: update copyright year in LICENSE and correct date format in text-features.html to 2026 2026-01-03 01:42:00 +01:00
codecalm
63a35a849c fix: fix EU flag svg
Some checks failed
Argos Tests / test (push) Has been cancelled
Bundlewatch / bundlewatch (push) Has been cancelled
Release / Release (push) Has been cancelled
2025-12-12 19:56:38 +01:00
ethancrawford
94e1a95ffb Allow Offcanvas docs page to scroll properly (#2565) 2025-12-11 20:07:12 +01:00
Paweł Kuna
83ec6f8bcc feat: add Tour component using Driver.js (#2549) 2025-12-08 21:08:22 +01:00
ethancrawford
e3d86c519b feat: upgrade apexcharts to v5 and add CSS variables for dynamic chart colors (#2555)
Co-authored-by: codecalm <codecalm@gmail.com>
2025-12-08 21:08:03 +01:00
Paweł Kuna
f9d6076014 refactor: Update build scripts and asset management across packages (#2558) 2025-12-02 18:51:54 +01:00
dependabot[bot]
f264470d8f chore(deps): bump actions/checkout from 5 to 6 (#2550)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-30 23:52:18 +01:00
188 changed files with 8233 additions and 3576 deletions

View File

@@ -1,6 +1,10 @@
>= 1%
last 2 versions
Firefox ESR
>= 0.5%
last 2 major versions
not dead
safari >= 15.4
iOS >= 15.4
Chrome >= 120
Firefox >= 121
iOS >= 15.6
Safari >= 15.6
not Explorer <= 11
Samsung >= 23
not kaios <= 2.5

View File

@@ -1,63 +0,0 @@
#!/usr/bin/env node
'use strict'
import { readFileSync, writeFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url'
import { sync } from 'glob';
import * as prettier from "prettier";
const __dirname = dirname(fileURLToPath(import.meta.url))
const docs = sync(join(__dirname, '..', 'docs', '**', '*.md'))
async function formatHTML(htmlString) {
try {
const formattedHtml = await prettier.format(htmlString, {
parser: "html",
printWidth: 100,
});
return formattedHtml;
} catch (error) {
console.error("Error formatting HTML:", error);
return htmlString; // Return original in case of an error
}
}
async function replaceAsync(str, regex, asyncFn) {
const matches = [...str.matchAll(regex)];
const replacements = await Promise.all(
matches.map(async (match) => asyncFn(...match))
);
let result = str;
matches.forEach((match, i) => {
result = result.replace(match[0], replacements[i]);
});
return result;
}
for (const file of docs) {
const oldContent = readFileSync(file, 'utf8')
// get codeblocks from markdown
const content = await replaceAsync(oldContent, /(```([a-z0-9]+).*?\n)(.*?)(```)/gs, async (m, m1, m2, m3, m4) => {
if (m2 === 'html') {
m3 = await formatHTML(m3);
// remove empty lines
m3 = m3.replace(/^\s*[\r\n]/gm, '');
return m1 + m3.trim() + "\n" + m4;
}
return m.trim();
})
if (content !== oldContent) {
writeFileSync(file, content, 'utf8')
console.log(`Reformatted ${file}`)
}
}

79
.build/reformat-mdx.ts Normal file
View File

@@ -0,0 +1,79 @@
#!/usr/bin/env node
import { readFileSync, writeFileSync } from 'node:fs'
import { join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { sync } from 'glob'
import * as prettier from 'prettier'
const __dirname = dirname(fileURLToPath(import.meta.url))
const docs: string[] = sync(join(__dirname, '..', 'docs', '**', '*.md'))
async function formatHTML(htmlString: string): Promise<string> {
try {
const formattedHtml = await prettier.format(htmlString, {
parser: 'html',
printWidth: 100,
})
return formattedHtml
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
console.error('Error formatting HTML:', errorMessage)
return htmlString // Return original in case of an error
}
}
async function replaceAsync(
str: string,
regex: RegExp,
asyncFn: (...args: string[]) => Promise<string>
): Promise<string> {
const matches = [...str.matchAll(regex)]
const replacements = await Promise.all(
matches.map(async (match: RegExpMatchArray) => asyncFn(...match))
)
let result = str
matches.forEach((match: RegExpMatchArray, i: number) => {
result = result.replace(match[0], replacements[i])
})
return result
}
async function processFiles(): Promise<void> {
for (const file of docs) {
const oldContent = readFileSync(file, 'utf8')
// get codeblocks from markdown
const content = await replaceAsync(
oldContent,
/(```([a-z0-9]+).*?\n)(.*?)(```)/gs,
async (m: string, m1: string, m2: string, m3: string, m4: string) => {
if (m2 === 'html') {
let formattedHtml = await formatHTML(m3)
// remove empty lines
formattedHtml = formattedHtml.replace(/^\s*[\r\n]/gm, '')
return m1 + formattedHtml.trim() + '\n' + m4
}
return m.trim()
}
)
if (content !== oldContent) {
writeFileSync(file, content, 'utf8')
console.log(`Reformatted ${file}`)
}
}
}
processFiles().catch((error) => {
const errorMessage = error instanceof Error ? error.message : String(error)
console.error('Error processing files:', errorMessage)
process.exit(1)
})

View File

@@ -0,0 +1,76 @@
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { defineConfig, type UserConfig } from 'vite'
interface CreateViteConfigOptions {
entry: string
name?: string
fileName: string | ((format: string) => string)
formats: ('es' | 'umd' | 'iife' | 'cjs')[]
outDir: string
banner?: string
minify?: boolean | 'esbuild'
}
/**
* Creates a Vite configuration for building libraries
*/
export function createViteConfig({
entry,
name,
fileName,
formats,
outDir,
banner,
minify = false
}: CreateViteConfigOptions): UserConfig {
const rollupOutput: {
generatedCode: {
constBindings: boolean
}
banner?: string
} = {
generatedCode: {
constBindings: true
}
}
// Add banner if provided
if (banner) {
rollupOutput.banner = banner
}
const config: UserConfig = {
build: {
lib: {
entry: path.resolve(entry),
name: name,
fileName: typeof fileName === 'function' ? fileName : () => fileName,
formats: formats
},
outDir: path.resolve(outDir),
emptyOutDir: false,
sourcemap: true,
rollupOptions: {
output: rollupOutput
},
target: 'es2015',
minify: minify
},
define: {
'process.env.NODE_ENV': '"production"'
},
esbuild: {
target: 'es2015',
tsconfigRaw: {
compilerOptions: {
module: 'ES2020',
target: 'ES2015'
}
}
}
}
return defineConfig(config)
}

View File

@@ -1,30 +0,0 @@
#!/usr/bin/env node
import AdmZip from 'adm-zip';
import path from 'path';
import { fileURLToPath } from 'url';
import { readFileSync } from 'fs';
// Get __dirname in ESM
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const pkg = JSON.parse(
readFileSync(path.join(__dirname, '../core', 'package.json'), 'utf8')
)
// Create zip instance and add folder
const zip = new AdmZip();
zip.addLocalFolder(path.join(__dirname, '../preview/dist'), 'dashboard');
zip.addLocalFile(path.join(__dirname, '../preview/static', 'og.png'), '.', 'preview.png');
zip.addFile("documentation.url", Buffer.from("[InternetShortcut]\nURL = https://tabler.io/docs"));
// Folder to zip and output path
const outputZipPath = path.join(__dirname, '../packages-zip', `tabler-${pkg.version}.zip`);
// Write the zip file
zip.writeZip(outputZipPath);
console.log(`Zipped folder to ${outputZipPath}`);

46
.build/zip-package.ts Normal file
View File

@@ -0,0 +1,46 @@
#!/usr/bin/env node
import AdmZip from 'adm-zip'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { readFileSync } from 'node:fs'
// Get __dirname in ESM
const __dirname = path.dirname(fileURLToPath(import.meta.url))
interface PackageJson {
version: string
[key: string]: unknown
}
const pkg: PackageJson = JSON.parse(
readFileSync(path.join(__dirname, '../core', 'package.json'), 'utf8')
)
// Create zip instance and add folder
const zip = new AdmZip()
zip.addLocalFolder(path.join(__dirname, '../preview/dist'), 'dashboard')
zip.addLocalFile(
path.join(__dirname, '../preview/static', 'og.png'),
'.',
'preview.png'
)
zip.addFile(
'documentation.url',
Buffer.from('[InternetShortcut]\nURL = https://tabler.io/docs')
)
// Folder to zip and output path
const outputZipPath = path.join(
__dirname,
'../packages-zip',
`tabler-${pkg.version}.zip`
)
// Write the zip file
zip.writeZip(outputZipPath)
console.log(`Zipped folder to ${outputZipPath}`)

View File

@@ -0,0 +1,6 @@
---
"@tabler/core": patch
---
Migrated `rgba()` functions to modern CSS color functions (`color-mix()` and `color-transparent()`) for better browser support and cleaner code. Replaced `rgba(var(--#{$prefix}*-rgb), ...)` with `color-mix(in srgb, var(--#{$prefix}*) ..., transparent)`, static percentage `color-mix()` with `color-transparent()`, and `rgba($variable, ...)` with `color-transparent($variable, ...)`.

View File

@@ -0,0 +1,8 @@
---
"@tabler/core": minor
"@tabler/preview": minor
"@tabler/docs": minor
---
Migrated build system from Rollup to Vite across all packages. Replaced `rollup.config.mjs` with `vite.config.mjs` and updated build scripts to use `vite build` instead of `rollup`. Build outputs remain identical (UMD and ESM formats) with no breaking changes for end users.

View File

@@ -0,0 +1,7 @@
---
"@tabler/core": patch
"@tabler/preview": patch
---
Added Driver.js library integration and Tour demo page for interactive product tours and onboarding guides.

View File

@@ -0,0 +1,6 @@
---
"@tabler/preview": patch
---
Updated `@tabler/icons` to v3.36.1.

View File

@@ -0,0 +1,7 @@
---
"@tabler/core": minor
"@tabler/preview": minor
---
Upgraded `apexcharts` from `3.54.1` to `5.3.6` and added CSS variables (`--chart-{id}-color-{index}`) for dynamic chart colors to fix compatibility with the new version.

View File

@@ -1,68 +0,0 @@
name: Argos Tests
on:
push:
branches:
- dev
pull_request:
paths:
- 'preview/**/*.js'
- 'preview/**/*.html'
- 'preview/**/*.scss'
- 'core/**/*.js'
- 'core/**/*.scss'
env:
NODE: 20
permissions:
contents: read
jobs:
test:
timeout-minutes: 60
runs-on: ubuntu-latest
# if: github.event.pull_request.draft == false
if: false
steps:
- name: Clone repository
uses: actions/checkout@v5
- name: Cache turbo build setup
uses: actions/cache@v4
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'
- name: Get installed Playwright version
id: playwright-version
run: echo "PLAYWRIGHT_VERSION=$(node -e "console.log(require('./package.json').devDependencies['@playwright/test'])")" >> $GITHUB_ENV
- name: Cache playwright binaries
uses: actions/cache@v4
id: playwright-cache
with:
path: |
~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }}
- name: Install pnpm dependencies
run: pnpm install
- name: Install Playwright Browsers
run: pnpm exec playwright install --with-deps
if: steps.playwright-cache.outputs.cache-hit != 'true'
- name: Run Playwright tests
run: pnpm run playwright

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

@@ -9,7 +9,7 @@ on:
env:
FORCE_COLOR: 2
NODE: 20
NODE: 22
jobs:
bundlewatch:
@@ -17,10 +17,10 @@ jobs:
steps:
- name: Clone repository
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Cache turbo build setup
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: .turbo
key: ${{ runner.os }}-turbo-${{ github.sha }}

View File

@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout Repo
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
persist-credentials: false

32
.github/workflows/lint.yml vendored Normal file
View File

@@ -0,0 +1,32 @@
name: Lint
on:
pull_request: null
env:
NODE: 22
permissions:
contents: read
jobs:
lint:
runs-on: ubuntu-latest
steps:
- name: Clone repository
uses: actions/checkout@v6
- name: Install PNPM
uses: pnpm/action-setup@v4
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: "${{ env.NODE }}"
cache: 'pnpm'
- name: Install pnpm dependencies
run: pnpm install
- name: Lint Markdown
run: pnpm run lint

View File

@@ -12,7 +12,7 @@ jobs:
name: Verify lock file integrity
steps:
- name: Clone Tabler
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Prevent lock file change
uses: xalvarez/prevent-file-change-action@v3
with:

View File

@@ -21,7 +21,7 @@ jobs:
pull-requests: write # to create pull request
steps:
- name: Checkout Repo
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Install PNPM
uses: pnpm/action-setup@v4

View File

@@ -1,10 +1,10 @@
name: Test build
name: Test
on:
pull_request: null
env:
NODE: 20
NODE: 22
permissions:
contents: read
@@ -14,10 +14,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Clone repository
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Cache turbo build setup
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: .turbo
key: ${{ runner.os }}-turbo-${{ github.sha }}
@@ -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

45
.github/workflows/type-check.yml vendored Normal file
View File

@@ -0,0 +1,45 @@
name: Type Check
on:
pull_request: null
push:
branches:
- main
- dev
env:
NODE: 20
permissions:
contents: read
jobs:
type-check:
runs-on: ubuntu-latest
steps:
- name: Clone repository
uses: actions/checkout@v6
- name: Cache turbo build setup
uses: actions/cache@v4
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'
- name: Install pnpm dependencies
run: pnpm install
- name: Run type-check
run: pnpm run type-check

12
.gitignore vendored
View File

@@ -36,4 +36,14 @@ package-lock.json
demo/
dist/
packages-zip/
.env
.env
sri.json
# TypeScript
*.tsbuildinfo
.tsbuildinfo
# Test coverage
coverage/
**/coverage/
*.lcov

61
.markdownlint.json Normal file
View File

@@ -0,0 +1,61 @@
{
"default": true,
"MD001": {
"level": 2
},
"MD003": {
"style": "atx"
},
"MD004": {
"style": "dash"
},
"MD007": {
"indent": 2
},
"MD009": {
"br_spaces": 2
},
"MD010": false,
"MD012": {
"maximum": 2
},
"MD013": {
"line_length": 120,
"code_blocks": false,
"tables": false,
"headings": false,
"headings_line_length": 120
},
"MD022": true,
"MD025": {
"front_matter_title": ""
},
"MD026": {
"punctuation": ".,;:!"
},
"MD030": {
"ul_single": 1,
"ul_multi": 1,
"ol_single": 1,
"ol_multi": 1
},
"MD031": true,
"MD032": true,
"MD033": false,
"MD034": false,
"MD035": {
"style": "---"
},
"MD036": false,
"MD037": true,
"MD038": true,
"MD039": true,
"MD040": true,
"MD041": {
"front_matter_title": ""
},
"MD046": {
"style": "fenced"
},
"MD047": true
}

2
.nvmrc
View File

@@ -1 +1 @@
20
22

View File

@@ -1,6 +1,6 @@
The MIT License (MIT)
Copyright (c) 2018-2025 The Tabler Authors
Copyright (c) 2018-2026 The Tabler Authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

@@ -1,18 +1,20 @@
#!/usr/bin/env node
'use strict'
import { readFileSync, writeFileSync } from 'node:fs';
import { join, dirname, basename } from 'node:path';
import { readFileSync, writeFileSync } from 'node:fs'
import { join, dirname, basename } from 'node:path'
import { fileURLToPath } from 'node:url'
import { sync } from 'glob';
import banner from '../../shared/banner/index.mjs';
import { sync } from 'glob'
import banner from '../../shared/banner/index.mjs'
const __dirname = dirname(fileURLToPath(import.meta.url))
const styles = sync(join(__dirname, '..', 'dist', 'css', '*.css'))
const styles: string[] = sync(join(__dirname, '..', 'dist', 'css', '*.css'))
const plugins = {
interface Plugins {
[key: string]: string
}
const plugins: Plugins = {
'tabler-flags': 'Flags',
'tabler-flags.rtl': 'Flags RTL',
'tabler-marketing': 'Marketing',
@@ -25,22 +27,24 @@ const plugins = {
'tabler-vendors.rtl': 'Vendors RTL',
}
styles.forEach((file, i) => {
styles.forEach((file: string) => {
const content = readFileSync(file, 'utf8')
const filename = basename(file)
const pluginKey = Object.keys(plugins).find(plugin => filename.includes(plugin))
const plugin = plugins[pluginKey]
const pluginKey = Object.keys(plugins).find((plugin: string) => filename.includes(plugin))
const plugin = pluginKey ? plugins[pluginKey] : undefined
const regex = /^(@charset ['"][a-zA-Z0-9-]+['"];?)\n?/i
let newContent = ''
const bannerText = banner(plugin)
if (content.match(regex)) {
newContent = content.replace(regex, (m, m1) => {
return `${m1}\n${banner(plugin)}\n`
newContent = content.replace(regex, (m: string, m1: string) => {
return `${m1}\n${bannerText}\n`
})
} else {
newContent = `${banner(plugin)}\n${content}`
newContent = `${bannerText}\n${content}`
}
writeFileSync(file, newContent, 'utf8')
})
})

View File

@@ -1,82 +0,0 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
// Get __dirname in ES modules
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// File paths (relative to core/.build directory)
const bootstrapPath = path.join(__dirname, '../node_modules/bootstrap/scss/_variables.scss');
const tablerPath = path.join(__dirname, '../scss/_variables.scss');
// Function to extract variable names from SCSS file
function extractVariables(filePath) {
const content = fs.readFileSync(filePath, 'utf8');
const variables = new Set();
// Regex to find SCSS variables
// Looks for patterns like: $variable-name: value
// Includes variables in maps and lists
const variableRegex = /\$([a-zA-Z0-9_-]+)\s*[:=]/g;
let match;
while ((match = variableRegex.exec(content)) !== null) {
const varName = match[1];
variables.add(varName);
}
return variables;
}
// Main function
function compareVariables() {
console.log('Analyzing Bootstrap variables...');
const bootstrapVars = extractVariables(bootstrapPath);
console.log(`Found ${bootstrapVars.size} variables in Bootstrap\n`);
console.log('Analyzing Tabler variables...');
const tablerVars = extractVariables(tablerPath);
console.log(`Found ${tablerVars.size} variables in Tabler\n`);
// Find variables that are in Bootstrap but not in Tabler
const missingInTabler = [];
for (const varName of bootstrapVars) {
if (!tablerVars.has(varName)) {
missingInTabler.push(varName);
}
}
// Sort alphabetically
missingInTabler.sort();
console.log('='.repeat(60));
console.log(`Variables in Bootstrap that are missing in Tabler: ${missingInTabler.length}`);
console.log('='.repeat(60));
if (missingInTabler.length === 0) {
console.log('All Bootstrap variables are present in Tabler!');
} else {
console.log('\nList of missing variables:\n');
missingInTabler.forEach((varName, index) => {
console.log(`${(index + 1).toString().padStart(4)}. $${varName}`);
});
}
// Optionally: show statistics
console.log('\n' + '='.repeat(60));
console.log('Statistics:');
console.log(` Bootstrap: ${bootstrapVars.size} variables`);
console.log(` Tabler: ${tablerVars.size} variables`);
console.log(` Missing: ${missingInTabler.length} variables`);
console.log(` Coverage: ${((1 - missingInTabler.length / bootstrapVars.size) * 100).toFixed(1)}%`);
console.log('='.repeat(60));
}
// Run analysis
try {
compareVariables();
} catch (error) {
console.error('Error during analysis:', error.message);
process.exit(1);
}

View File

@@ -0,0 +1,84 @@
import { readFileSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
// Get __dirname in ES modules
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
// File paths (relative to core/.build directory)
const bootstrapPath = path.join(__dirname, '../node_modules/bootstrap/scss/_variables.scss')
const tablerPath = path.join(__dirname, '../scss/_variables.scss')
// Function to extract variable names from SCSS file
function extractVariables(filePath: string): Set<string> {
const content = readFileSync(filePath, 'utf8')
const variables = new Set<string>()
// Regex to find SCSS variables
// Looks for patterns like: $variable-name: value
// Includes variables in maps and lists
const variableRegex = /\$([a-zA-Z0-9_-]+)\s*[:=]/g
let match: RegExpExecArray | null
while ((match = variableRegex.exec(content)) !== null) {
const varName = match[1]
variables.add(varName)
}
return variables
}
// Main function
function compareVariables(): void {
console.log('Analyzing Bootstrap variables...')
const bootstrapVars = extractVariables(bootstrapPath)
console.log(`Found ${bootstrapVars.size} variables in Bootstrap\n`)
console.log('Analyzing Tabler variables...')
const tablerVars = extractVariables(tablerPath)
console.log(`Found ${tablerVars.size} variables in Tabler\n`)
// Find variables that are in Bootstrap but not in Tabler
const missingInTabler: string[] = []
for (const varName of bootstrapVars) {
if (!tablerVars.has(varName)) {
missingInTabler.push(varName)
}
}
// Sort alphabetically
missingInTabler.sort()
console.log('='.repeat(60))
console.log(`Variables in Bootstrap that are missing in Tabler: ${missingInTabler.length}`)
console.log('='.repeat(60))
if (missingInTabler.length === 0) {
console.log('All Bootstrap variables are present in Tabler!')
} else {
console.log('\nList of missing variables:\n')
missingInTabler.forEach((varName: string, index: number) => {
console.log(`${(index + 1).toString().padStart(4)}. $${varName}`)
})
}
// Optionally: show statistics
console.log('\n' + '='.repeat(60))
console.log('Statistics:')
console.log(` Bootstrap: ${bootstrapVars.size} variables`)
console.log(` Tabler: ${tablerVars.size} variables`)
console.log(` Missing: ${missingInTabler.length} variables`)
console.log(` Coverage: ${((1 - missingInTabler.length / bootstrapVars.size) * 100).toFixed(1)}%`)
console.log('='.repeat(60))
}
// Run analysis
try {
compareVariables()
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
console.error('Error during analysis:', errorMessage)
process.exit(1)
}

View File

@@ -1,19 +1,30 @@
#!/usr/bin/env node
'use strict'
import { existsSync, mkdirSync, lstatSync } from 'fs'
import { existsSync, mkdirSync } from 'node:fs'
import { emptyDirSync, copySync } from 'fs-extra/esm'
import libs from '../libs.json' with { type: 'json' }
import { fileURLToPath } from 'url'
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url'
import { join, dirname } from 'node:path'
const __dirname = dirname(fileURLToPath(import.meta.url))
interface LibConfig {
npm?: string
js?: string[]
css?: string[]
head?: boolean
}
interface Libs {
[key: string]: LibConfig
}
const libsData = libs as Libs
emptyDirSync(join(__dirname, '..', 'dist/libs'))
for(const name in libs) {
const { npm } = libs[name]
for (const name in libsData) {
const { npm } = libsData[name]
if (npm) {
const from = join(__dirname, '..', `node_modules/${npm}`)
@@ -23,11 +34,12 @@ for(const name in libs) {
if (!existsSync(to)) {
mkdirSync(to, { recursive: true })
}
copySync(from, to, {
dereference: true,
})
console.log(`Successfully copied ${npm}`)
}
}
}

View File

@@ -1,13 +1,18 @@
const crypto = require('node:crypto');
const fs = require('node:fs');
const path = require('node:path');
const sh = require('shelljs');
import * as crypto from 'node:crypto'
import { readFileSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
sh.config.fatal = true
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const configFile = path.join(__dirname, '../../shared/data/sri.json')
const files = [
interface FileConfig {
file: string
configPropertyName: string
}
const files: FileConfig[] = [
{
file: 'dist/css/tabler.min.css',
configPropertyName: 'css'
@@ -80,28 +85,37 @@ const files = [
file: 'dist/js/tabler-theme.min.js',
configPropertyName: 'js-theme'
},
// {
// file: 'dist/preview/css/demo.min.css',
// configPropertyName: 'demo-css'
// },
// {
// file: 'dist/preview/js/demo.min.js',
// configPropertyName: 'demo-js'
// },
]
for (const { file, configPropertyName } of files) {
fs.readFile(path.join(__dirname, '..', file), 'utf8', (error, data) => {
if (error) {
function generateSRI(): void {
const sriData: Record<string, string> = {}
for (const { file, configPropertyName } of files) {
try {
const filePath = path.join(__dirname, '..', file)
const data = readFileSync(filePath, 'utf8')
const algorithm = 'sha384'
const hash = crypto.createHash(algorithm).update(data, 'utf8').digest('base64')
const integrity = `${algorithm}-${hash}`
console.log(`${configPropertyName}: ${integrity}`)
sriData[configPropertyName] = integrity
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
console.error(`Error processing ${file}:`, errorMessage)
throw error
}
}
const algorithm = 'sha384'
const hash = crypto.createHash(algorithm).update(data, 'utf8').digest('base64')
const integrity = `${algorithm}-${hash}`
writeFileSync(configFile, JSON.stringify(sriData, null, 2) + '\n', 'utf8')
}
console.log(`${configPropertyName}: ${integrity}`)
try {
generateSRI()
} catch (error) {
console.error('Failed to generate SRI:', error)
process.exit(1)
}
sh.sed('-i', new RegExp(`^(\\s+"${configPropertyName}":\\s+["'])\\S*(["'])`), `$1${integrity}$2`, configFile)
})
}

View File

@@ -1,10 +1,8 @@
#!/usr/bin/env node
'use strict'
import { existsSync, mkdirSync } from 'fs'
import { existsSync, mkdirSync } from 'node:fs'
import { copySync } from 'fs-extra/esm'
import { fileURLToPath } from 'url'
import { fileURLToPath } from 'node:url'
import { join, dirname } from 'node:path'
const __dirname = dirname(fileURLToPath(import.meta.url))
@@ -25,11 +23,11 @@ if (existsSync(monoFrom)) {
if (!existsSync(monoTo)) {
mkdirSync(monoTo, { recursive: true })
}
copySync(monoFrom, monoTo, {
dereference: true,
})
console.log(`Successfully copied geist-mono fonts`)
} else {
console.warn(`Warning: geist-mono fonts not found at ${monoFrom}`)
@@ -43,11 +41,11 @@ if (existsSync(sansFrom)) {
if (!existsSync(sansTo)) {
mkdirSync(sansTo, { recursive: true })
}
copySync(sansFrom, sansTo, {
dereference: true,
})
console.log(`Successfully copied geist-sans fonts`)
} else {
console.warn(`Warning: geist-sans fonts not found at ${sansFrom}`)

View File

@@ -1,47 +0,0 @@
import path from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
import { babel } from '@rollup/plugin-babel'
import { nodeResolve } from '@rollup/plugin-node-resolve'
import replace from '@rollup/plugin-replace'
import banner from '../../shared/banner/index.mjs'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const ESM = process.env.ESM === 'true'
const THEME = process.env.THEME === 'true'
const external = []
const plugins = [
babel({
exclude: 'node_modules/**',
babelHelpers: 'bundled'
})
]
plugins.push(
replace({
'process.env.NODE_ENV': '"production"',
preventAssignment: true
}),
nodeResolve()
)
const destinationFile = `tabler${THEME ? '-theme' : ''}${ESM ? '.esm' : ''}`
const rollupConfig = {
input: path.resolve(__dirname, `../js/tabler${THEME ? '-theme' : ''}.js`),
output: {
banner: banner(),
file: path.resolve(__dirname, `../dist/js/${destinationFile}.js`),
format: ESM ? 'esm' : 'umd',
generatedCode: 'es2015'
},
external,
plugins
}
if (!ESM) {
rollupConfig.output.name = `tabler${THEME ? '-theme' : ''}`
}
export default rollupConfig

View File

@@ -0,0 +1,33 @@
import path from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
import { existsSync } from 'node:fs'
import { createViteConfig } from '../../.build/vite.config.helper'
import getBanner from '../../shared/banner/index.mjs'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const ESM = process.env.ESM === 'true'
const THEME = process.env.THEME === 'true'
const MINIFY = process.env.MINIFY === 'true'
const destinationFile = `tabler${THEME ? '-theme' : ''}${ESM ? '.esm' : ''}`
const entryFile = `tabler${THEME ? '-theme' : ''}`
const libraryName = `tabler${THEME ? '-theme' : ''}`
const bannerText = getBanner()
// Try .ts first, fallback to .js for gradual migration
const entryPath = path.resolve(__dirname, `../js/${entryFile}`)
const entry = existsSync(`${entryPath}.ts`) ? `${entryPath}.ts` : `${entryPath}.js`
export default createViteConfig({
entry: entry,
name: ESM ? undefined : libraryName,
fileName: () => MINIFY ? `${destinationFile}.min.js` : `${destinationFile}.js`,
formats: [ESM ? 'es' : 'umd'],
outDir: path.resolve(__dirname, '../dist/js'),
banner: bannerText,
minify: MINIFY ? true : false
})

View File

@@ -106,7 +106,6 @@
### Minor Changes
- a2640e2: Add Playwright configuration and visual regression tests
- d3ae77c: Enable `scrollSpy` in `countup` module
- bd3d959: Refactor SCSS files to replace divide function with calc
- cb278c7: Add Segmented Control component

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="21" height="16" fill="none"><mask id="a" width="21" height="16" x="0" y="0" mask-type="alpha" maskUnits="userSpaceOnUse"><path fill="#fff" d="M.001.927h20v15h-20z"/></mask><g mask="url(#a)"><path fill="#F7FCFF" fill-rule="evenodd" d="M.001.927v15h20v-15h-20Z" clip-rule="evenodd"/><mask id="b" width="21" height="16" x="0" y="0" mask-type="alpha" maskUnits="userSpaceOnUse"><path fill="#fff" fill-rule="evenodd" d="M.001.927v15h20v-15h-20Z" clip-rule="evenodd"/></mask><g fill-rule="evenodd" clip-rule="evenodd" mask="url(#b)"><path fill="#3D58DB" d="M.001.927v15h20v-15h-20Z"/><path fill="#FFD018" d="m9.407 3.137-.14.818L10 3.57l.735.386-.14-.818.594-.64h-.821L10 1.695l-.367.804h-.822l.595.639Zm0 10.855-.14.819.734-.387.735.387-.14-.819.594-.639h-.821L10 12.55l-.367.804h-.822l.595.64ZM3.484 9.438l.14-.818-.594-.64h.822l.367-.803.367.804h.822l-.595.639.14.818-.734-.386-.735.386Zm1.352 1.77-.14.818.734-.386.735.386-.14-.818.594-.64h-.821l-.368-.803-.367.804H4.24l.595.639Zm9.009.818.14-.818-.595-.64h.822l.367-.803.368.804h.821l-.594.639.14.818-.735-.386-.734.386Zm-9.01-6.062-.14.818.735-.386.735.386-.14-.818.594-.639h-.821l-.368-.804-.367.804H4.24l.595.64Zm9.01.818.14-.818-.595-.639h.822l.367-.804.368.804h.821l-.594.64.14.817-.735-.386-.734.386ZM6.66 13.29l-.14.819.735-.387.734.386-.14-.818.595-.639h-.822l-.367-.804-.368.804h-.821l.594.64Zm5.418.819.14-.819-.594-.639h.821l.367-.804.368.804h.821l-.594.64.14.817-.735-.386-.734.386ZM6.52 4.666l.735-.387.734.387-.14-.818.595-.64h-.822l-.367-.804-.368.804h-.821l.594.64-.14.818Zm5.558 0 .14-.818-.594-.64h.821l.367-.804.368.804h.821l-.594.64.14.818-.735-.387-.734.387Zm3.062 3.879-.14.818.735-.386.735.386-.14-.818.593-.64h-.82l-.368-.803-.368.804h-.821l.594.639Z"/></g></g></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="15" fill="none" viewBox="0 0 20 15"><path fill="#3d58db" fill-rule="evenodd" d="M0 0v15h20V0z" clip-rule="evenodd"/><path fill="#ffd018" fill-rule="evenodd" d="m9.407 2.442-.14.818.733-.385.735.386-.14-.818.594-.64h-.821L10 1l-.367.804h-.822zm0 10.855-.14.819.734-.387.735.387-.14-.819.594-.639h-.821L10 11.855l-.367.804h-.822l.595.64zM3.484 8.743l.14-.818-.594-.64h.822l.367-.803.367.804h.822l-.595.639.14.818-.734-.386zm1.352 1.77-.14.818.734-.386.735.386-.14-.818.594-.64h-.821L5.43 9.07l-.367.804H4.24zm9.009.818.14-.818-.595-.64h.822l.367-.803.368.804h.821l-.594.639.14.818-.735-.386zm-9.01-6.062-.14.818.735-.386.735.386-.14-.818.594-.639h-.821l-.368-.804-.367.804H4.24zm9.01.818.14-.818-.595-.639h.822l.367-.804.368.804h.821l-.594.64.14.817-.735-.386zM6.66 12.595l-.14.819.735-.387.734.386-.14-.818.595-.639h-.822l-.367-.804-.368.804h-.821zm5.418.819.14-.819-.594-.639h.821l.367-.804.368.804h.821l-.594.64.14.817-.735-.386zM6.52 3.971l.735-.387.734.387-.14-.818.595-.64h-.822l-.367-.804-.368.804h-.821l.594.64zm5.558 0 .14-.818-.594-.64h.821l.367-.804.368.804h.821l-.594.64.14.818-.735-.387zM15.14 7.85l-.14.818.735-.386.735.386-.14-.818.593-.64h-.82l-.368-.803-.368.804h-.821z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -1,8 +0,0 @@
// Autosize plugin
const elements = document.querySelectorAll('[data-bs-toggle="autosize"]')
if (elements.length) {
elements.forEach(function (element) {
window.autosize && window.autosize(element)
})
}

107
core/js/src/autosize.ts Normal file
View File

@@ -0,0 +1,107 @@
import autosize from 'autosize'
/**
* --------------------------------------------------------------------------
* 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,20 +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,25 +0,0 @@
const elements = document.querySelectorAll('[data-countup]')
if (elements.length) {
elements.forEach(function (element) {
let options = {}
try {
const dataOptions = element.getAttribute('data-countup') ? JSON.parse(element.getAttribute('data-countup')) : {}
options = Object.assign(
{
enableScrollSpy: true,
},
dataOptions,
)
} catch (error) {}
const value = parseInt(element.innerHTML, 10)
if (window.countUp && window.countUp.CountUp) {
const countUp = new window.countUp.CountUp(element, value, options)
if (!countUp.error) {
countUp.start()
}
}
})
}

139
core/js/src/countup.ts Normal file
View File

@@ -0,0 +1,139 @@
import { CountUp } from 'countup.js'
/**
* --------------------------------------------------------------------------
* 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 = this.element.getAttribute('data-countup') ? JSON.parse(this.element.getAttribute('data-countup')!) : {}
options = Object.assign(
{
enableScrollSpy: true,
},
dataOptions,
)
} catch (error) {
// ignore invalid JSON
}
this.options = options
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,12 +0,0 @@
import { Dropdown } from './bootstrap'
/*
Core dropdowns
*/
let dropdownTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="dropdown"]'))
dropdownTriggerList.map(function (dropdownTriggerEl) {
let options = {
boundary: dropdownTriggerEl.getAttribute('data-bs-boundary') === 'viewport' ? document.querySelector('.btn') : 'clippingParents',
}
return new Dropdown(dropdownTriggerEl, options)
})

12
core/js/src/dropdown.ts Normal file
View File

@@ -0,0 +1,12 @@
import { Dropdown } from 'bootstrap'
/*
Core dropdowns
*/
const dropdownTriggerList: HTMLElement[] = [].slice.call(document.querySelectorAll<HTMLElement>('[data-bs-toggle="dropdown"]'))
dropdownTriggerList.map(function (dropdownTriggerEl: HTMLElement) {
const options = {
boundary: dropdownTriggerEl.getAttribute('data-bs-boundary') === 'viewport' ? document.querySelector('.btn') : 'clippingParents',
}
return new Dropdown(dropdownTriggerEl, options)
})

29
core/js/src/global.d.ts vendored Normal file
View File

@@ -0,0 +1,29 @@
// Global type declarations for window properties
interface Window {
autosize?: (element: HTMLElement | HTMLTextAreaElement) => void
countUp?: {
CountUp: new (target: HTMLElement, endVal: number, options?: any) => {
error: boolean
start: () => void
}
}
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 +0,0 @@
// Input mask plugin
var maskElementList = [].slice.call(document.querySelectorAll('[data-mask]'))
maskElementList.map(function (maskEl) {
window.IMask &&
new window.IMask(maskEl, {
mask: maskEl.dataset.mask,
lazy: maskEl.dataset['mask-visible'] === 'true',
})
})

153
core/js/src/input-mask.ts Normal file
View File

@@ -0,0 +1,153 @@
import IMask, { type InputMask as IMaskInputMask } from 'imask'
/**
* --------------------------------------------------------------------------
* 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,11 +1,11 @@
import { Popover } from './bootstrap'
import { Popover } from 'bootstrap'
/*
Core popovers
*/
let popoverTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="popover"]'))
popoverTriggerList.map(function (popoverTriggerEl) {
let options = {
const popoverTriggerList: HTMLElement[] = [].slice.call(document.querySelectorAll<HTMLElement>('[data-bs-toggle="popover"]'))
popoverTriggerList.map(function (popoverTriggerEl: HTMLElement) {
const options = {
delay: { show: 50, hide: 50 },
html: popoverTriggerEl.getAttribute('data-bs-html') === 'true',
placement: popoverTriggerEl.getAttribute('data-bs-placement') ?? 'auto',

View File

@@ -2,11 +2,11 @@
// Initializes Sortable on elements marked with [data-sortable]
// Allows options via JSON in data attribute: data-sortable='{"animation":150}'
const sortableElements = document.querySelectorAll('[data-sortable]')
const sortableElements: NodeListOf<HTMLElement> = document.querySelectorAll<HTMLElement>('[data-sortable]')
if (sortableElements.length) {
sortableElements.forEach(function (element) {
let options = {}
sortableElements.forEach(function (element: HTMLElement) {
let options: Record<string, any> = {}
try {
const rawOptions = element.getAttribute('data-sortable')

View File

@@ -1,11 +0,0 @@
/*
Switch icons
*/
let switchesTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="switch-icon"]'))
switchesTriggerList.map(function (switchTriggerEl) {
switchTriggerEl.addEventListener('click', (e) => {
e.stopPropagation()
switchTriggerEl.classList.toggle('active')
})
})

147
core/js/src/switch-icon.ts Normal file
View File

@@ -0,0 +1,147 @@
/**
* --------------------------------------------------------------------------
* Tabler switch-icon.js
* --------------------------------------------------------------------------
*/
/**
* 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,16 +0,0 @@
import { Tab } from './bootstrap'
export const EnableActivationTabsFromLocationHash = () => {
const locationHash = window.location.hash
if (locationHash) {
const tabsList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tab"]'))
const matchedTabs = tabsList.filter((tab) => tab.hash === locationHash)
matchedTabs.map((tab) => {
new Tab(tab).show()
})
}
}
EnableActivationTabsFromLocationHash()

16
core/js/src/tab.ts Normal file
View File

@@ -0,0 +1,16 @@
import { Tab } from 'bootstrap'
export const EnableActivationTabsFromLocationHash = (): void => {
const locationHash: string = window.location.hash
if (locationHash) {
const tabsList: HTMLAnchorElement[] = [].slice.call(document.querySelectorAll<HTMLAnchorElement>('[data-bs-toggle="tab"]'))
const matchedTabs = tabsList.filter((tab: HTMLAnchorElement) => tab.hash === locationHash)
matchedTabs.map((tab: HTMLAnchorElement) => {
new Tab(tab).show()
})
}
}
EnableActivationTabsFromLocationHash()

View File

@@ -1,12 +1,12 @@
export const prefix = 'tblr-'
export const prefix: string = 'tblr-'
export const hexToRgba = (hex, opacity) => {
export const hexToRgba = (hex: string, opacity: number): string | null => {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
return result ? `rgba(${parseInt(result[1], 16)}, ${parseInt(result[2], 16)}, ${parseInt(result[3], 16)}, ${opacity})` : null
}
export const getColor = (color, opacity = 1) => {
export const getColor = (color: string, opacity: number = 1): string | null => {
const c = getComputedStyle(document.body).getPropertyValue(`--${prefix}${color}`).trim()
if (opacity !== 1) {

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,17 +0,0 @@
import { Toast } from './bootstrap'
/*
Toasts
*/
let toastsTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="toast"]'))
toastsTriggerList.map(function (toastTriggerEl) {
if (!toastTriggerEl.hasAttribute('data-bs-target')) {
return
}
const toastEl = new Toast(toastTriggerEl.getAttribute('data-bs-target'))
toastTriggerEl.addEventListener('click', () => {
toastEl.show()
})
})

18
core/js/src/toast.ts Normal file
View File

@@ -0,0 +1,18 @@
import { Toast } from 'bootstrap'
/*
Toasts
*/
const toastsTriggerList: HTMLElement[] = [].slice.call(document.querySelectorAll<HTMLElement>('[data-bs-toggle="toast"]'))
toastsTriggerList.map(function (toastTriggerEl: HTMLElement) {
const target = toastTriggerEl.getAttribute('data-bs-target')
if (target === null) {
return
}
const toastEl = new Toast(target)
toastTriggerEl.addEventListener('click', () => {
toastEl.show()
})
})

View File

@@ -1,11 +0,0 @@
import { Tooltip } from './bootstrap'
let tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'))
tooltipTriggerList.map(function (tooltipTriggerEl) {
let options = {
delay: { show: 50, hide: 50 },
html: tooltipTriggerEl.getAttribute('data-bs-html') === 'true',
placement: tooltipTriggerEl.getAttribute('data-bs-placement') ?? 'auto',
}
return new Tooltip(tooltipTriggerEl, options)
})

11
core/js/src/tooltip.ts Normal file
View File

@@ -0,0 +1,11 @@
import { Tooltip } from 'bootstrap'
const tooltipTriggerList: HTMLElement[] = [].slice.call(document.querySelectorAll<HTMLElement>('[data-bs-toggle="tooltip"]'))
tooltipTriggerList.map(function (tooltipTriggerEl: HTMLElement) {
const options = {
delay: { show: 50, hide: 50 },
html: tooltipTriggerEl.getAttribute('data-bs-html') === 'true',
placement: tooltipTriggerEl.getAttribute('data-bs-placement') ?? 'auto',
}
return new Tooltip(tooltipTriggerEl, options)
})

View File

@@ -1,35 +0,0 @@
/**
* demo-theme 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.
*/
const 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, prop) => searchParams.get(prop),
})
for (const key in themeConfig) {
const param = params[key]
let selectedValue
if (!!param) {
localStorage.setItem('tabler-' + key, param)
selectedValue = param
} else {
const storedTheme = localStorage.getItem('tabler-' + key)
selectedValue = storedTheme ? storedTheme : themeConfig[key]
}
if (selectedValue !== themeConfig[key]) {
document.documentElement.setAttribute('data-bs-' + key, selectedValue)
} else {
document.documentElement.removeAttribute('data-bs-' + key)
}
}

7
core/js/tabler-theme.ts Normal file
View File

@@ -0,0 +1,7 @@
/**
* 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.
*/
import './src/theme'

View File

@@ -1,16 +0,0 @@
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/sortable'
// Re-export everything from bootstrap.js (single source of truth)
export * from './src/bootstrap'
// Re-export tabler namespace
export * as tabler from './src/tabler'

56
core/js/tabler.ts Normal file
View File

@@ -0,0 +1,56 @@
import './src/dropdown'
import './src/tooltip'
import './src/popover'
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'
// 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

@@ -166,5 +166,14 @@
"dist/turbo.es2017-umd.js"
],
"head": true
},
"driver.js": {
"npm": "driver.js",
"js": [
"dist/driver.js.iife.js"
],
"css": [
"dist/driver.css"
]
}
}

View File

@@ -5,41 +5,46 @@
"homepage": "https://tabler.io",
"scripts": {
"dev": "pnpm run clean && pnpm run copy && pnpm run watch",
"build": "pnpm run clean && pnpm run css && pnpm run js && pnpm run copy && pnpm run generate-sri",
"build": "pnpm run clean && pnpm run build-assets && pnpm run copy && pnpm run generate-sri",
"build-assets": "concurrently \"pnpm run css\" \"pnpm run js\"",
"clean": "shx rm -rf dist demo",
"css": "pnpm run css-compile && pnpm run css-prefix && pnpm run css-rtl && pnpm run css-minify && pnpm run css-banner",
"css-compile": "sass --no-source-map --load-path=node_modules --style expanded scss/:dist/css/",
"css-banner": "node .build/add-banner.mjs",
"css": "pnpm run css-build && pnpm run css-prefix && pnpm run css-rtl && pnpm run css-minify && pnpm run css-banner",
"css-build": "sass --no-source-map --load-path=node_modules --style expanded scss/:dist/css/",
"css-banner": "tsx .build/add-banner.ts",
"css-prefix": "postcss --config .build/postcss.config.mjs --replace \"dist/css/*.css\" \"!dist/css/*.rtl*.css\" \"!dist/css/*.min.css\"",
"css-rtl": "cross-env NODE_ENV=RTL postcss --config .build/postcss.config.mjs --dir \"dist/css\" --ext \".rtl.css\" \"dist/css/*.css\" \"!dist/css/*.min.css\" \"!dist/css/*.rtl.css\"",
"css-minify": "pnpm run css-minify-main && pnpm run css-minify-rtl",
"css-minify": "concurrently \"pnpm run css-minify-main\" \"pnpm run css-minify-rtl\"",
"css-minify-main": "cleancss -O1 --format breakWith=lf --with-rebase --source-map --source-map-inline-sources --output dist/css/ --batch --batch-suffix \".min\" \"dist/css/*.css\" \"!dist/css/*.min.css\" \"!dist/css/*rtl*.css\"",
"css-minify-rtl": "cleancss -O1 --format breakWith=lf --with-rebase --source-map --source-map-inline-sources --output dist/css/ --batch --batch-suffix \".min\" \"dist/css/*rtl.css\" \"!dist/css/*.min.css\"",
"css-lint": "pnpm run css-lint-variables",
"css-lint-variables": "find-unused-sass-variables scss/ node_modules/bootstrap/scss/",
"js": "pnpm run js-compile && pnpm run js-minify",
"js-compile": "pnpm run js-compile-standalone && pnpm run js-compile-standalone-esm && pnpm run js-compile-theme && pnpm run js-compile-theme-esm",
"js-compile-theme-esm": "rollup --environment THEME:true --environment ESM:true --config .build/rollup.config.mjs --sourcemap",
"js-compile-theme": "rollup --environment THEME:true --config .build/rollup.config.mjs --sourcemap",
"js-compile-standalone": "rollup --config .build/rollup.config.mjs --sourcemap",
"js-compile-standalone-esm": "rollup --environment ESM:true --config .build/rollup.config.mjs --sourcemap",
"js-minify": "pnpm run js-minify-standalone && pnpm run js-minify-standalone-esm && pnpm run js-minify-theme && pnpm run js-minify-theme-esm",
"js-minify-standalone": "terser --compress passes=2 --mangle --comments \"/^!/\" --source-map \"content=dist/js/tabler.js.map,includeSources,url=tabler.min.js.map\" --output dist/js/tabler.min.js dist/js/tabler.js",
"js-minify-standalone-esm": "terser --compress passes=2 --mangle --comments \"/^!/\" --source-map \"content=dist/js/tabler.esm.js.map,includeSources,url=tabler.esm.min.js.map\" --output dist/js/tabler.esm.min.js dist/js/tabler.esm.js",
"js-minify-theme": "terser --compress passes=2 --mangle --comments \"/^!/\" --source-map \"content=dist/js/tabler-theme.js.map,includeSources,url=tabler-theme.min.js.map\" --output dist/js/tabler-theme.min.js dist/js/tabler-theme.js",
"js-minify-theme-esm": "terser --compress passes=2 --mangle --comments \"/^!/\" --source-map \"content=dist/js/tabler-theme.esm.js.map,includeSources,url=tabler-theme.esm.min.js.map\" --output dist/js/tabler-theme.esm.min.js dist/js/tabler-theme.esm.js",
"copy": "pnpm run copy-img && pnpm run copy-libs && pnpm run copy-fonts",
"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.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.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": "node .build/copy-libs.mjs",
"copy-libs": "tsx .build/copy-libs.ts",
"copy-fonts": "shx mkdir -p dist/fonts && shx cp -rf fonts/* dist/fonts",
"import-fonts": "node .build/import-fonts.mjs",
"import-fonts": "tsx .build/import-fonts.ts",
"watch": "concurrently \"pnpm run watch-css\" \"pnpm run watch-js\"",
"watch-css": "nodemon --watch scss/ --ext scss --exec \"pnpm run css-compile && pnpm run css-prefix\"",
"watch-js": "nodemon --watch js/ --ext js --exec \"pnpm run js-compile\"",
"watch-css": "nodemon --watch scss/ --ext scss --exec \"pnpm run css-build && pnpm run css-prefix\"",
"watch-js": "nodemon --watch js/ --ext ts,js --exec \"pnpm run js-build\"",
"bundlewatch": "bundlewatch",
"generate-sri": "node .build/generate-sri.js",
"format:check": "prettier --check \"scss/**/*.scss\" \"js/**/*.js\" --cache",
"format:write": "prettier --write \"scss/**/*.scss\" \"js/**/*.js\" --cache"
"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",
"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",
@@ -69,7 +74,7 @@
"files": [
"docs/**/*",
"dist/**/*",
"js/**/*.{js,map}",
"js/**/*.{ts,js,map}",
"img/**/*.{svg}",
"scss/**/*.scss",
"libs.json"
@@ -155,11 +160,16 @@
"devDependencies": {
"@hotwired/turbo": "^8.0.18",
"@melloware/coloris": "^0.25.0",
"apexcharts": "3.54.1",
"@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",
@@ -168,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",
@@ -177,7 +188,9 @@
"sortablejs": "^1.15.6",
"star-rating.js": "^4.3.1",
"tom-select": "^2.4.3",
"typed.js": "^2.1.0"
"typed.js": "^2.1.0",
"typescript": "^5.9.3",
"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))};

File diff suppressed because it is too large Load Diff

View File

@@ -33,12 +33,7 @@ $enable-deprecation-messages: true !default;
$enable-important-utilities: true !default;
// Escaped Characters
$escaped-characters: (
('<', '%3c'),
('>', '%3e'),
('#', '%23'),
('(', '%28'),
(')', '%29')) !default;
$escaped-characters: (('<', '%3c'), ('>', '%3e'), ('#', '%23'), ('(', '%28'), (')', '%29')) !default;
// Dark Mode
$color-mode-type: data !default;
@@ -560,11 +555,6 @@ $text-secondary-opacity: 0.7 !default;
$text-secondary-light-opacity: 0.4 !default;
$text-secondary-dark-opacity: 0.8 !default;
$border-opacity: 0.16 !default;
$border-light-opacity: 0.08 !default;
$border-dark-opacity: 0.24 !default;
$border-active-opacity: 0.58 !default;
$bg-surface: var(--#{$prefix}white) !default;
$bg-surface-secondary: var(--#{$prefix}gray-100) !default;
$bg-surface-tertiary: var(--#{$prefix}gray-50) !default;
@@ -572,12 +562,8 @@ $bg-surface-dark: var(--#{$prefix}dark) !default;
$body-text-align: null !default;
$body-bg: $gray-50 !default;
$body-color: $dark !default;
$body-color: $gray-800 !default;
$body-emphasis-color: $gray-700 !default;
$body-secondary-color: rgba($body-color, 0.75) !default;
$body-secondary-bg: $gray-200 !default;
$body-tertiary-color: rgba($body-color, 0.5) !default;
$body-tertiary-bg: $gray-100 !default;
$color-contrast-dark: $body-color !default;
$color-contrast-light: $light !default;
@@ -587,20 +573,27 @@ $text-secondary: $gray-500 !default;
$text-secondary-light: $gray-400 !default;
$text-secondary-dark: $gray-600 !default;
$border-color: $gray-200 !default;
$border-color-translucent: rgba(4, 32, 69, 0.1);
$border-light-color: var(--#{$prefix}gray-200) !default;
$border-light-opacity: 4.7% !default;
$border-light-color-translucent: color-mix(in srgb, var(--#{$prefix}gray-800) #{$border-light-opacity}, transparent) !default;
$border-dark-color: $gray-400 !default;
$border-dark-color-translucent: rgba(4, 32, 69, 0.27);
$border-color: var(--#{$prefix}gray-200) !default;
$border-opacity: 11.9% !default;
$border-color-translucent: color-mix(in srgb, var(--#{$prefix}gray-800) #{$border-opacity}, transparent) !default;
$border-active-color: color.mix($text-secondary, #ffffff, math.percentage($border-active-opacity)) !default;
$border-active-color-translucent: rgba($text-secondary, $border-active-opacity) !default;
$border-dark-color: var(--#{$prefix}gray-300) !default;
$border-dark-opacity: 20.7% !default;
$border-dark-color-translucent: color-mix(in srgb, var(--#{$prefix}gray-800) #{$border-dark-opacity}, transparent) !default;
$active-bg: rgba(var(--#{$prefix}primary-rgb), 0.04) !default;
$border-active-color: var(--#{$prefix}gray-400) !default;
$border-active-opacity: 44.8% !default;
$border-active-color-translucent: color-mix(in srgb, var(--#{$prefix}gray-800) #{$border-active-opacity}, transparent) !default;
$active-bg: color-transparent(var(--#{$prefix}primary), 0.04) !default;
$active-color: var(--#{$prefix}primary) !default;
$active-border-color: var(--#{$prefix}primary) !default;
$hover-bg: rgba(var(--#{$prefix}secondary-rgb), 0.08) !default;
$hover-bg: color-transparent(var(--#{$prefix}secondary), 0.08) !default;
$disabled-bg: var(--#{$prefix}bg-surface-secondary) !default;
$disabled-color: color-transparent(var(--#{$prefix}body-color), 0.4) !default;
@@ -880,7 +873,7 @@ $avatar-sizes: (
brand-size: 2rem,
),
) !default;
$avatar-border-radius: var(--#{$prefix}border-radius) !default;
$avatar-border-radius: var(--#{$prefix}border-radius-pill) !default;
$avatar-font-size: $h4-font-size !default;
$avatar-box-shadow: var(--#{$prefix}shadow-border) !default;
$avatar-list-spacing: -0.5;
@@ -988,14 +981,14 @@ $aspect-ratios: (
) !default;
// Shadows
$box-shadow: rgba(var(--#{$prefix}body-color-rgb), 0.04) 0 2px 4px 0 !default;
$box-shadow: color-transparent(var(--#{$prefix}body-color), 0.04) 0 2px 4px 0 !default;
$box-shadow-sm: 0 0.125rem 0.25rem rgba($black, 0.075) !default;
$box-shadow-lg: 0 1rem 3rem rgba($black, 0.175) !default;
$box-shadow-transparent: 0 0 0 0 transparent !default;
$box-shadow-border: inset 0 0 0 1px var(--#{$prefix}border-color-translucent) !default;
$box-shadow-input: 0 1px 1px rgba(var(--#{$prefix}body-color-rgb), 0.06) !default;
$box-shadow-card: 0 0 4px rgba(var(--#{$prefix}body-color-rgb), 0.04) !default;
$box-shadow-card-hover: rgba(var(--#{$prefix}body-color-rgb), 0.16) 0 2px 16px 0 !default;
$box-shadow-input: 0 1px 1px color-transparent(var(--#{$prefix}body-color), 0.06) !default;
$box-shadow-card: 0px 1px 3px rgba(0, 0, 0, 0.08) !default;
$box-shadow-card-hover: color-transparent(var(--#{$prefix}body-color), 0.16) 0 2px 16px 0 !default;
$box-shadow-dropdown:
0 16px 24px 2px rgba(0, 0, 0, 0.07),
0 6px 30px 5px rgba(0, 0, 0, 0.06),
@@ -1020,7 +1013,7 @@ $component-active-bg: $primary !default;
// Focus
$focus-ring-width: 0.25rem !default;
$focus-ring-opacity: 0.25 !default;
$focus-ring-color: rgba(var(--#{$prefix}primary-rgb), $focus-ring-opacity) !default;
$focus-ring-color: color-mix(in srgb, var(--#{$prefix}primary) #{percentage($focus-ring-opacity)}, transparent) !default;
$focus-ring-blur: 0 !default;
$focus-ring-box-shadow: 0 0 $focus-ring-blur $focus-ring-width $focus-ring-color !default;
@@ -1166,9 +1159,9 @@ $btn-padding-y-lg: $input-btn-padding-y-lg !default;
$btn-padding-x-lg: $input-btn-padding-x-lg !default;
// Inputs
$input-bg: var(--#{$prefix}body-bg) !default;
$input-bg: var(--#{$prefix}bg-forms) !default;
$input-disabled-color: null !default;
$input-disabled-bg: var(--#{$prefix}secondary-bg) !default;
$input-disabled-bg: var(--#{$prefix}bg-surface-secondary) !default;
$input-disabled-border-color: null !default;
$input-height: null !default;
@@ -1517,9 +1510,9 @@ $navbar-light-active-color: var(--#{$prefix}body-color) !default;
$navbar-light-hover-color: var(--#{$prefix}body-color) !default;
$navbar-light-disabled-color: var(--#{$prefix}disabled-color) !default;
$navbar-light-active-bg: rgba(0, 0, 0, 0.2) !default;
$navbar-light-icon-color: rgba($body-color, 0.75) !default;
$navbar-light-icon-color: color-transparent(var(--#{$prefix}body-color), 0.75) !default;
$navbar-light-toggler-icon-bg: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'><path stroke='#{$navbar-light-icon-color}' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/></svg>") !default;
$navbar-light-toggler-border-color: rgba(var(--#{$prefix}emphasis-color-rgb), 0.15) !default;
$navbar-light-toggler-border-color: color-transparent(var(--#{$prefix}emphasis-color), 0.15) !default;
$navbar-light-brand-hover-color: $navbar-light-active-color !default;
$navbar-dark-color: rgba($white, $text-secondary-opacity) !default;
@@ -1694,7 +1687,7 @@ $table-active-bg: var(--#{$prefix}active-bg) !default;
$table-hover-color: $table-color !default;
$table-hover-bg-factor: 0.075 !default;
$table-hover-bg: rgba(var(--#{$prefix}emphasis-color-rgb), $table-hover-bg-factor) !default;
$table-hover-bg: color-mix(in srgb, var(--#{$prefix}emphasis-color) #{percentage($table-hover-bg-factor)}, transparent) !default;
$table-caption-color: var(--#{$prefix}secondary-color) !default;
@@ -1729,7 +1722,7 @@ $toast-box-shadow: var(--#{$prefix}box-shadow) !default;
$toast-spacing: $container-padding-x !default;
$toast-header-color: var(--#{$prefix}gray-500) !default;
$toast-header-background-color: rgba(var(--#{$prefix}body-bg-rgb), 0.85) !default;
$toast-header-background-color: color-transparent(var(--#{$prefix}body-bg), 0.85) !default;
$toast-header-border-color: $toast-border-color !default;
// Tracking
@@ -1774,8 +1767,6 @@ $list-group-action-active-color: var(--#{$prefix}body-color) !default;
$list-group-action-active-bg: var(--#{$prefix}secondary-bg) !default;
// Forms
$input-bg: var(--#{$prefix}bg-forms) !default;
$input-disabled-bg: $disabled-bg !default;
$input-border-color: var(--#{$prefix}border-color) !default;
$input-border-color-translucent: var(--#{$prefix}border-color-translucent) !default;
@@ -1865,16 +1856,16 @@ $form-select-font-size-lg: $input-font-size-lg !default;
$form-select-border-radius-lg: $input-border-radius-lg !default;
$form-select-transition: $input-transition !default;
$form-switch-color: rgba($black, 0.25) !default;
$form-switch-color: white !default;
$form-switch-width: 2rem !default;
$form-switch-height: 1.25rem !default;
$form-switch-padding-start: $form-switch-width + 0.5rem !default;
$form-switch-bg-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'><circle r='3' fill='#{$border-color}'/></svg>") !default;
$form-switch-bg-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'><circle r='3' fill='#{$form-switch-color}'/></svg>") !default;
$form-switch-border-radius: $form-switch-width !default;
$form-switch-transition: background-position 0.15s ease-in-out !default;
$form-switch-focus-color: $input-focus-border-color !default;
$form-switch-focus-color: white !default;
$form-switch-focus-bg-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'><circle r='3' fill='#{$form-switch-focus-color}'/></svg>") !default;
$form-switch-checked-color: $component-active-color !default;
$form-switch-checked-color: white !default;
$form-switch-checked-bg-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'><circle r='3' fill='#{$form-switch-checked-color}'/></svg>") !default;
$form-switch-checked-bg-position: right center !default;
$form-switch-bg-size: auto !default;
@@ -1960,7 +1951,7 @@ $form-validation-states: (
'icon': $form-feedback-icon-valid,
'tooltip-color': #fff,
'tooltip-bg-color': var(--#{$prefix}success),
'focus-box-shadow': 0 0 $input-btn-focus-blur $input-focus-width rgba(var(--#{$prefix}success-rgb), $input-btn-focus-color-opacity),
'focus-box-shadow': 0 0 $input-btn-focus-blur $input-focus-width color-mix(in srgb, var(--#{$prefix}success) #{percentage($input-btn-focus-color-opacity)}, transparent),
'border-color': var(--#{$prefix}form-valid-border-color),
),
'invalid': (
@@ -1968,7 +1959,7 @@ $form-validation-states: (
'icon': $form-feedback-icon-invalid,
'tooltip-color': #fff,
'tooltip-bg-color': var(--#{$prefix}danger),
'focus-box-shadow': 0 0 $input-btn-focus-blur $input-focus-width rgba(var(--#{$prefix}danger-rgb), $input-btn-focus-color-opacity),
'focus-box-shadow': 0 0 $input-btn-focus-blur $input-focus-width color-mix(in srgb, var(--#{$prefix}danger) #{percentage($input-btn-focus-color-opacity)}, transparent),
'border-color': var(--#{$prefix}form-invalid-border-color),
),
) !default;

View File

@@ -1,184 +1,203 @@
// Geist Sans Font Family
@font-face {
font-family: 'Geist';
src: url('#{$assets-base}/fonts/geist-sans/Geist-Thin.woff2') format('woff2'),
url('#{$assets-base}/fonts/geist-sans/Geist-Thin.ttf') format('truetype');
font-weight: 100;
font-style: normal;
font-display: swap;
font-family: 'Geist';
src:
url('#{$assets-base}/fonts/geist-sans/Geist-Thin.woff2') format('woff2'),
url('#{$assets-base}/fonts/geist-sans/Geist-Thin.ttf') format('truetype');
font-weight: 100;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Geist';
src: url('#{$assets-base}/fonts/geist-sans/Geist-UltraLight.woff2') format('woff2'),
url('#{$assets-base}/fonts/geist-sans/Geist-UltraLight.ttf') format('truetype');
font-weight: 200;
font-style: normal;
font-display: swap;
font-family: 'Geist';
src:
url('#{$assets-base}/fonts/geist-sans/Geist-UltraLight.woff2') format('woff2'),
url('#{$assets-base}/fonts/geist-sans/Geist-UltraLight.ttf') format('truetype');
font-weight: 200;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Geist';
src: url('#{$assets-base}/fonts/geist-sans/Geist-Light.woff2') format('woff2'),
url('#{$assets-base}/fonts/geist-sans/Geist-Light.ttf') format('truetype');
font-weight: 300;
font-style: normal;
font-display: swap;
font-family: 'Geist';
src:
url('#{$assets-base}/fonts/geist-sans/Geist-Light.woff2') format('woff2'),
url('#{$assets-base}/fonts/geist-sans/Geist-Light.ttf') format('truetype');
font-weight: 300;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Geist';
src: url('#{$assets-base}/fonts/geist-sans/Geist-Regular.woff2') format('woff2'),
url('#{$assets-base}/fonts/geist-sans/Geist-Regular.ttf') format('truetype');
font-weight: 400;
font-style: normal;
font-display: swap;
font-family: 'Geist';
src:
url('#{$assets-base}/fonts/geist-sans/Geist-Regular.woff2') format('woff2'),
url('#{$assets-base}/fonts/geist-sans/Geist-Regular.ttf') format('truetype');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Geist';
src: url('#{$assets-base}/fonts/geist-sans/Geist-Medium.woff2') format('woff2'),
url('#{$assets-base}/fonts/geist-sans/Geist-Medium.ttf') format('truetype');
font-weight: 500;
font-style: normal;
font-display: swap;
font-family: 'Geist';
src:
url('#{$assets-base}/fonts/geist-sans/Geist-Medium.woff2') format('woff2'),
url('#{$assets-base}/fonts/geist-sans/Geist-Medium.ttf') format('truetype');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Geist';
src: url('#{$assets-base}/fonts/geist-sans/Geist-SemiBold.woff2') format('woff2'),
url('#{$assets-base}/fonts/geist-sans/Geist-SemiBold.ttf') format('truetype');
font-weight: 600;
font-style: normal;
font-display: swap;
font-family: 'Geist';
src:
url('#{$assets-base}/fonts/geist-sans/Geist-SemiBold.woff2') format('woff2'),
url('#{$assets-base}/fonts/geist-sans/Geist-SemiBold.ttf') format('truetype');
font-weight: 600;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Geist';
src: url('#{$assets-base}/fonts/geist-sans/Geist-Bold.woff2') format('woff2'),
url('#{$assets-base}/fonts/geist-sans/Geist-Bold.ttf') format('truetype');
font-weight: 700;
font-style: normal;
font-display: swap;
font-family: 'Geist';
src:
url('#{$assets-base}/fonts/geist-sans/Geist-Bold.woff2') format('woff2'),
url('#{$assets-base}/fonts/geist-sans/Geist-Bold.ttf') format('truetype');
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Geist';
src: url('#{$assets-base}/fonts/geist-sans/Geist-Black.woff2') format('woff2'),
url('#{$assets-base}/fonts/geist-sans/Geist-Black.ttf') format('truetype');
font-weight: 800;
font-style: normal;
font-display: swap;
font-family: 'Geist';
src:
url('#{$assets-base}/fonts/geist-sans/Geist-Black.woff2') format('woff2'),
url('#{$assets-base}/fonts/geist-sans/Geist-Black.ttf') format('truetype');
font-weight: 800;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Geist';
src: url('#{$assets-base}/fonts/geist-sans/Geist-UltraBlack.woff2') format('woff2'),
url('#{$assets-base}/fonts/geist-sans/Geist-UltraBlack.ttf') format('truetype');
font-weight: 900;
font-style: normal;
font-display: swap;
font-family: 'Geist';
src:
url('#{$assets-base}/fonts/geist-sans/Geist-UltraBlack.woff2') format('woff2'),
url('#{$assets-base}/fonts/geist-sans/Geist-UltraBlack.ttf') format('truetype');
font-weight: 900;
font-style: normal;
font-display: swap;
}
// Geist Sans Variable Font
@font-face {
font-family: 'Geist';
src: url('#{$assets-base}/fonts/geist-sans/Geist-Variable.woff2') format('woff2'),
url('#{$assets-base}/fonts/geist-sans/Geist-Variable.ttf') format('truetype');
font-weight: 100 900;
font-style: normal;
font-display: swap;
font-family: 'Geist';
src:
url('#{$assets-base}/fonts/geist-sans/Geist-Variable.woff2') format('woff2'),
url('#{$assets-base}/fonts/geist-sans/Geist-Variable.ttf') format('truetype');
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
// Geist Mono Font Family
@font-face {
font-family: 'Geist Mono';
src: url('#{$assets-base}/fonts/geist-mono/GeistMono-Thin.woff2') format('woff2'),
url('#{$assets-base}/fonts/geist-mono/GeistMono-Thin.ttf') format('truetype');
font-weight: 100;
font-style: normal;
font-display: swap;
font-family: 'Geist Mono';
src:
url('#{$assets-base}/fonts/geist-mono/GeistMono-Thin.woff2') format('woff2'),
url('#{$assets-base}/fonts/geist-mono/GeistMono-Thin.ttf') format('truetype');
font-weight: 100;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Geist Mono';
src: url('#{$assets-base}/fonts/geist-mono/GeistMono-UltraLight.woff2') format('woff2'),
url('#{$assets-base}/fonts/geist-mono/GeistMono-UltraLight.ttf') format('truetype');
font-weight: 200;
font-style: normal;
font-display: swap;
font-family: 'Geist Mono';
src:
url('#{$assets-base}/fonts/geist-mono/GeistMono-UltraLight.woff2') format('woff2'),
url('#{$assets-base}/fonts/geist-mono/GeistMono-UltraLight.ttf') format('truetype');
font-weight: 200;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Geist Mono';
src: url('#{$assets-base}/fonts/geist-mono/GeistMono-Light.woff2') format('woff2'),
url('#{$assets-base}/fonts/geist-mono/GeistMono-Light.ttf') format('truetype');
font-weight: 300;
font-style: normal;
font-display: swap;
font-family: 'Geist Mono';
src:
url('#{$assets-base}/fonts/geist-mono/GeistMono-Light.woff2') format('woff2'),
url('#{$assets-base}/fonts/geist-mono/GeistMono-Light.ttf') format('truetype');
font-weight: 300;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Geist Mono';
src: url('#{$assets-base}/fonts/geist-mono/GeistMono-Regular.woff2') format('woff2'),
url('#{$assets-base}/fonts/geist-mono/GeistMono-Regular.ttf') format('truetype');
font-weight: 400;
font-style: normal;
font-display: swap;
font-family: 'Geist Mono';
src:
url('#{$assets-base}/fonts/geist-mono/GeistMono-Regular.woff2') format('woff2'),
url('#{$assets-base}/fonts/geist-mono/GeistMono-Regular.ttf') format('truetype');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Geist Mono';
src: url('#{$assets-base}/fonts/geist-mono/GeistMono-Medium.woff2') format('woff2'),
url('#{$assets-base}/fonts/geist-mono/GeistMono-Medium.ttf') format('truetype');
font-weight: 500;
font-style: normal;
font-display: swap;
font-family: 'Geist Mono';
src:
url('#{$assets-base}/fonts/geist-mono/GeistMono-Medium.woff2') format('woff2'),
url('#{$assets-base}/fonts/geist-mono/GeistMono-Medium.ttf') format('truetype');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Geist Mono';
src: url('#{$assets-base}/fonts/geist-mono/GeistMono-SemiBold.woff2') format('woff2'),
url('#{$assets-base}/fonts/geist-mono/GeistMono-SemiBold.ttf') format('truetype');
font-weight: 600;
font-style: normal;
font-display: swap;
font-family: 'Geist Mono';
src:
url('#{$assets-base}/fonts/geist-mono/GeistMono-SemiBold.woff2') format('woff2'),
url('#{$assets-base}/fonts/geist-mono/GeistMono-SemiBold.ttf') format('truetype');
font-weight: 600;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Geist Mono';
src: url('#{$assets-base}/fonts/geist-mono/GeistMono-Bold.woff2') format('woff2'),
url('#{$assets-base}/fonts/geist-mono/GeistMono-Bold.ttf') format('truetype');
font-weight: 700;
font-style: normal;
font-display: swap;
font-family: 'Geist Mono';
src:
url('#{$assets-base}/fonts/geist-mono/GeistMono-Bold.woff2') format('woff2'),
url('#{$assets-base}/fonts/geist-mono/GeistMono-Bold.ttf') format('truetype');
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Geist Mono';
src: url('#{$assets-base}/fonts/geist-mono/GeistMono-Black.woff2') format('woff2'),
url('#{$assets-base}/fonts/geist-mono/GeistMono-Black.ttf') format('truetype');
font-weight: 800;
font-style: normal;
font-display: swap;
font-family: 'Geist Mono';
src:
url('#{$assets-base}/fonts/geist-mono/GeistMono-Black.woff2') format('woff2'),
url('#{$assets-base}/fonts/geist-mono/GeistMono-Black.ttf') format('truetype');
font-weight: 800;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Geist Mono';
src: url('#{$assets-base}/fonts/geist-mono/GeistMono-UltraBlack.woff2') format('woff2'),
url('#{$assets-base}/fonts/geist-mono/GeistMono-UltraBlack.ttf') format('truetype');
font-weight: 900;
font-style: normal;
font-display: swap;
font-family: 'Geist Mono';
src:
url('#{$assets-base}/fonts/geist-mono/GeistMono-UltraBlack.woff2') format('woff2'),
url('#{$assets-base}/fonts/geist-mono/GeistMono-UltraBlack.ttf') format('truetype');
font-weight: 900;
font-style: normal;
font-display: swap;
}
// Geist Mono Variable Font
@font-face {
font-family: 'Geist Mono';
src: url('#{$assets-base}/fonts/geist-mono/GeistMono-Variable.woff2') format('woff2'),
url('#{$assets-base}/fonts/geist-mono/GeistMono-Variable.ttf') format('truetype');
font-weight: 100 900;
font-style: normal;
font-display: swap;
font-family: 'Geist Mono';
src:
url('#{$assets-base}/fonts/geist-mono/GeistMono-Variable.woff2') format('woff2'),
url('#{$assets-base}/fonts/geist-mono/GeistMono-Variable.ttf') format('truetype');
font-weight: 100 900;
font-style: normal;
font-display: swap;
}

View File

@@ -119,7 +119,7 @@
content: '';
}
>* {
> * {
position: absolute;
top: 0;
inset-inline-start: 0;
@@ -141,4 +141,4 @@
outline: 0;
// By default, there is no `--bs-focus-ring-x`, `--bs-focus-ring-y`, or `--bs-focus-ring-blur`, but we provide CSS variables with fallbacks to initial `0` values
box-shadow: var(--#{$prefix}focus-ring-x, 0) var(--#{$prefix}focus-ring-y, 0) var(--#{$prefix}focus-ring-blur, 0) var(--#{$prefix}focus-ring-width) var(--#{$prefix}focus-ring-color);
}
}

View File

@@ -69,7 +69,7 @@
.nav-item.active:after {
border-bottom-width: 0;
border-inline-start-width: 3px;
inset-inline-end: auto;
inset-inline-end: auto;
top: 0;
bottom: 0;
}
@@ -118,7 +118,7 @@ Navbar
.badge {
position: absolute;
top: 0.5rem;
inset-inline-end: 0.5rem;
inset-inline-end: 0.5rem;
transform: translate(50%, -50%);
}
}
@@ -151,8 +151,8 @@ Navbar
&:after {
content: '';
position: absolute;
inset-inline-start: 0;
inset-inline-end: 0;
inset-inline-start: 0;
inset-inline-end: 0;
bottom: -0.25rem;
border: 0 var(--#{$prefix}border-style) var(--#{$prefix}navbar-active-border-color);
border-bottom-width: 2px;
@@ -235,7 +235,7 @@ Navbar toggler
border-radius: inherit;
background: inherit;
position: absolute;
inset-inline-start: 0;
inset-inline-start: 0;
@include transition(inherit);
}
@@ -313,7 +313,7 @@ Navbar vertical
width: $sidebar-width;
position: fixed;
top: 0;
inset-inline-start: 0;
inset-inline-start: 0;
bottom: 0;
z-index: $zindex-fixed;
align-items: start;
@@ -323,8 +323,8 @@ Navbar vertical
&.navbar-right,
&.navbar-end {
inset-inline-start: auto;
inset-inline-end: 0;
inset-inline-start: auto;
inset-inline-end: 0;
}
.navbar-brand {
@@ -384,8 +384,8 @@ Navbar vertical
height: $navbar-overlap-height;
position: absolute;
top: 100%;
inset-inline-start: 0;
inset-inline-end: 0;
inset-inline-start: 0;
inset-inline-end: 0;
background: inherit;
z-index: -1;
box-shadow: inherit;

View File

@@ -67,8 +67,8 @@
content: '';
position: absolute;
top: 0;
inset-inline-start: 0;
inset-inline-end: 0;
inset-inline-start: 0;
inset-inline-end: 0;
bottom: 0;
background-image: $overlay-gradient;
}

View File

@@ -33,7 +33,10 @@
--#{$prefix}border-color-translucent: #{$border-color-translucent};
--#{$prefix}border-dark-color: #{$border-dark-color};
--#{$prefix}border-dark-color-translucent: #{$border-dark-color-translucent};
--#{$prefix}border-light-color: #{$border-light-color};
--#{$prefix}border-light-color-translucent: #{$border-light-color-translucent};
--#{$prefix}border-active-color: #{$border-active-color};
--#{$prefix}border-active-color-translucent: #{$border-active-color-translucent};
--#{$prefix}icon-color: #{$icon-color};

View File

@@ -144,12 +144,12 @@
// stylelint-disable scss/dollar-variable-pattern
@function rgba-css-var($identifier, $target) {
@if $identifier == 'body' and $target == 'bg' {
@return rgba(var(--#{$prefix}#{$identifier}-bg-rgb), var(--#{$prefix}#{$target}-opacity));
@return color-mix(in srgb, var(--#{$prefix}#{$identifier}-bg) calc(var(--#{$prefix}#{$target}-opacity) * 100%), transparent);
}
@if $identifier == 'body' and $target == 'text' {
@return rgba(var(--#{$prefix}#{$identifier}-color-rgb), var(--#{$prefix}#{$target}-opacity));
@return color-mix(in srgb, var(--#{$prefix}#{$identifier}-color) calc(var(--#{$prefix}#{$target}-opacity) * 100%), transparent);
} @else {
@return rgba(var(--#{$prefix}#{$identifier}-rgb), var(--#{$prefix}#{$target}-opacity));
@return color-mix(in srgb, var(--#{$prefix}#{$identifier}) calc(var(--#{$prefix}#{$target}-opacity) * 100%), transparent);
}
}

View File

@@ -59,10 +59,10 @@
@mixin focus-ring($show-border: false) {
outline: 0;
box-shadow: 0 0 $focus-ring-blur $focus-ring-width rgba(var(--#{$prefix}primary-rgb), 0.25);
box-shadow: 0 0 $focus-ring-blur $focus-ring-width color-transparent(var(--#{$prefix}primary), 0.25);
@if ($show-border) {
border-color: rgba(var(--#{$prefix}primary-rgb), 0.25);
border-color: color-transparent(var(--#{$prefix}primary), 0.25);
}
}

View File

@@ -15,17 +15,17 @@
}
[data-bs-theme-base='gray'] {
--#{$prefix}gray-50: #f9fafb;
--#{$prefix}gray-100: #f3f4f6;
--#{$prefix}gray-200: #e5e7eb;
--#{$prefix}gray-300: #d1d5db;
--#{$prefix}gray-400: #9ca3af;
--#{$prefix}gray-500: #6b7280;
--#{$prefix}gray-600: #4b5563;
--#{$prefix}gray-700: #374151;
--#{$prefix}gray-800: #1f2937;
--#{$prefix}gray-900: #111827;
--#{$prefix}gray-950: #030712;
--#{$prefix}gray-50: $gray-50;
--#{$prefix}gray-100: $gray-100;
--#{$prefix}gray-200: $gray-200;
--#{$prefix}gray-300: $gray-300;
--#{$prefix}gray-400: $gray-400;
--#{$prefix}gray-500: $gray-500;
--#{$prefix}gray-600: $gray-600;
--#{$prefix}gray-700: $gray-700;
--#{$prefix}gray-800: $gray-800;
--#{$prefix}gray-900: $gray-900;
--#{$prefix}gray-950: $gray-950;
}
[data-bs-theme-base='zinc'] {

View File

@@ -1,10 +1,12 @@
.alert {
--#{$prefix}alert-color: var(--#{$prefix}body-color);
--#{$prefix}alert-bg: #{color-transparent(var(--#{$prefix}alert-color), 0.1)};
--#{$prefix}alert-variant-color: var(--#{$prefix}body-color);
--#{$prefix}alert-color: var(--#{$prefix}alert-variant-color);
--#{$prefix}alert-bg: #{color-transparent(var(--#{$prefix}alert-variant-color), 0.16, var(--#{$prefix}bg-surface))};
--#{$prefix}alert-padding-x: #{$alert-padding-x};
--#{$prefix}alert-padding-y: #{$alert-padding-y};
--#{$prefix}alert-margin-bottom: #{$alert-margin-bottom};
--#{$prefix}alert-border-color: #{color-transparent(var(--#{$prefix}alert-color), 0.2)};
--#{$prefix}alert-border-color: #{color-transparent(var(--#{$prefix}alert-variant-color), 0.2, var(--#{$prefix}bg-surface))};
--#{$prefix}alert-border-color: var(--#{$prefix}border-color);
--#{$prefix}alert-border: var(--#{$prefix}border-width) solid var(--#{$prefix}alert-border-color);
--#{$prefix}alert-border-radius: var(--#{$prefix}border-radius);
--#{$prefix}alert-link-color: inherit;
@@ -16,6 +18,8 @@
background-color: color-mix(in srgb, var(--#{$prefix}alert-bg), var(--#{$prefix}bg-surface));
border-radius: var(--#{$prefix}alert-border-radius);
border: var(--#{$prefix}border-width) var(--#{$prefix}border-style) var(--#{$prefix}alert-border-color);
box-shadow: var(--#{$prefix}box-shadow);
color: var(--#{$prefix}alert-color);
display: flex;
flex-direction: row;
gap: 1rem;
@@ -66,15 +70,14 @@
.btn-close {
position: absolute;
top: calc(var(--#{$prefix}alert-padding-x) / 2 - 1px);
inset-inline-end: calc(var(--#{$prefix}alert-padding-y) / 2 - 1px);
inset-inline-end: calc(var(--#{$prefix}alert-padding-y) / 2 - 1px);
z-index: 1;
padding: calc(var(--#{$prefix}alert-padding-y) * 1.25) var(--#{$prefix}alert-padding-x);
}
}
.alert-important {
border-color: var(--#{$prefix}alert-color);
background-color: var(--#{$prefix}alert-color);
background-color: var(--#{$prefix}alert-variant-color);
color: var(--#{$prefix}white);
.alert-description {
@@ -93,6 +96,6 @@
@each $name, $color in $theme-colors {
.alert-#{$name} {
--#{$prefix}alert-color: var(--#{$prefix}#{$name});
--#{$prefix}alert-variant-color: var(--#{$prefix}#{$name});
}
}

View File

@@ -9,6 +9,7 @@
--#{$prefix}avatar-font-size: #{$avatar-font-size};
--#{$prefix}avatar-icon-size: #{$avatar-icon-size};
--#{$prefix}avatar-brand-size: #{$avatar-brand-size};
--#{$prefix}avatar-border-radius: #{$avatar-border-radius};
position: relative;
width: var(--#{$prefix}avatar-size);
height: var(--#{$prefix}avatar-size);
@@ -24,7 +25,7 @@
vertical-align: bottom;
user-select: none;
background: var(--#{$prefix}avatar-bg) no-repeat center/cover;
border-radius: $avatar-border-radius;
border-radius: var(--#{$prefix}avatar-border-radius);
box-shadow: var(--#{$prefix}avatar-box-shadow);
transition:
color $transition-time,
@@ -38,7 +39,7 @@
.badge {
position: absolute;
inset-inline-end: 0;
inset-inline-end: 0;
bottom: 0;
border-radius: $border-radius-pill;
box-shadow: 0 0 0 calc(var(--#{$prefix}avatar-status-size) / 4) $card-bg;
@@ -58,6 +59,10 @@
border-radius: $border-radius-pill;
}
.avatar-square {
border-radius: var(--#{$prefix}border-radius);
}
@each $avatar-size, $size in $avatar-sizes {
.avatar-#{$avatar-size} {
--#{$prefix}avatar-size: #{map.get($size, size)};
@@ -66,14 +71,14 @@
--#{$prefix}avatar-icon-size: #{map.get($size, icon-size)};
--#{$prefix}avatar-brand-size: #{map.get($size, brand-size)};
@if map.has-key($size, border-radius) {
border-radius: map.get($size, border-radius);
}
.badge:empty {
width: map.get($size, status-size);
height: map.get($size, status-size);
}
&.avatar-square {
--#{$prefix}avatar-border-radius: #{map.get($size, border-radius)};
}
}
}
@@ -96,10 +101,13 @@
--#{$prefix}list-gap: 0;
.avatar {
margin-inline-end: calc(#{$avatar-list-spacing} * var(--#{$prefix}avatar-size)) !important;
box-shadow:
var(--#{$prefix}avatar-box-shadow),
0 0 0 2px var(--#{$prefix}card-bg, var(--#{$prefix}bg-surface));
&:not(:first-child) {
margin-inline-start: calc(#{$avatar-list-spacing} * var(--#{$prefix}avatar-size)) !important;
}
}
}

View File

@@ -76,6 +76,15 @@
//
// Button color variations
//
.btn-ghost {
--#{$prefix}btn-bg: transparent;
--#{$prefix}btn-border-color: transparent;
--#{$prefix}btn-box-shadow: none;
--#{$prefix}btn-hover-bg: var(--#{$prefix}bg-surface-secondary);
--#{$prefix}btn-hover-border-color: transparent;
--#{$prefix}btn-hover-color: var(--#{$prefix}body-color);
}
@each $color, $value in map.merge($theme-colors, $social-colors) {
.btn-#{$color} {
@if $color == 'dark' {
@@ -114,15 +123,6 @@
--#{$prefix}btn-disabled-border-color: var(--#{$prefix}#{$color});
}
.btn-ghost {
--#{$prefix}btn-bg: transparent;
--#{$prefix}btn-border-color: transparent;
--#{$prefix}btn-box-shadow: none;
--#{$prefix}btn-hover-bg: var(--#{$prefix}bg-surface-secondary);
--#{$prefix}btn-hover-border-color: transparent;
--#{$prefix}btn-hover-color: var(--#{$prefix}body-color);
}
.btn-ghost-#{$color},
.btn-ghost.btn-#{$color} {
--#{$prefix}btn-color: var(--#{$prefix}#{$color});
@@ -250,7 +250,7 @@
position: absolute;
width: var(--#{$prefix}btn-icon-size);
height: var(--#{$prefix}btn-icon-size);
inset-inline-start: calc(50% - var(--#{$prefix}btn-icon-size) / 2);
inset-inline-start: calc(50% - var(--#{$prefix}btn-icon-size) / 2);
top: calc(50% - var(--#{$prefix}btn-icon-size) / 2);
animation: spinner-border 0.75s linear infinite;
}

View File

@@ -77,11 +77,11 @@
&:before {
position: absolute;
top: 50%;
inset-inline-end: 0;
inset-inline-start: 0;
inset-inline-end: 0;
inset-inline-start: 0;
height: 1.4rem;
content: '';
background: rgba(var(--#{$prefix}primary-rgb), 0.1);
background: color-transparent(var(--#{$prefix}primary), 0.1);
transform: translateY(-50%);
}
@@ -95,10 +95,10 @@
}
&.range-start:before {
inset-inline-start: 50%;
inset-inline-start: 50%;
}
&.range-end:before {
inset-inline-end: 50%;
inset-inline-end: 50%;
}
}

View File

@@ -40,7 +40,6 @@
// Card borderless
.card-borderless {
&,
.card-header,
.card-footer {
@@ -48,6 +47,18 @@
}
}
// Card dashed
.card-dashed {
border: var(--#{$prefix}border-width) dashed var(--#{$prefix}border-color);
}
// Card transparent
.card-transparent {
background: transparent;
border: var(--#{$prefix}border-width) dashed var(--#{$prefix}border-color);
box-shadow: none;
}
// Card stamp
.card-stamp {
--#{$prefix}stamp-size: 7rem;
@@ -141,7 +152,7 @@
background: $active-bg;
}
&+& {
& + & {
border-inline-start: var(--#{$prefix}border-width) var(--#{$prefix}border-style) var(--#{$prefix}border-color);
}
}
@@ -340,17 +351,17 @@ Stacked card
margin-bottom: 0;
}
.card-sm>& {
.card-sm > & {
padding: 1rem;
}
.card-md>& {
.card-md > & {
@include media-breakpoint-up(md) {
padding: 2.5rem;
}
}
.card-lg>& {
.card-lg > & {
@include media-breakpoint-up(md) {
padding: 2rem;
}
@@ -364,7 +375,7 @@ Stacked card
padding: 0;
}
&+& {
& + & {
border-top: var(--#{$prefix}border-width) var(--#{$prefix}border-style) var(--#{$prefix}border-color);
}
}
@@ -427,7 +438,6 @@ Card table
margin-bottom: 0 !important;
tr {
td,
th {
&:first-child {
@@ -456,11 +466,11 @@ Card table
tfoot {
&:last-child {
tr:last-child {
>*:last-child {
> *:last-child {
border-end-end-radius: calc(var(--#{$prefix}card-border-radius) - var(--#{$prefix}card-border-width));
}
>*:first-child {
> *:first-child {
border-end-start-radius: calc(var(--#{$prefix}card-border-radius) - var(--#{$prefix}card-border-width));
}
}
@@ -496,7 +506,7 @@ Card table
}
}
.card-body+& {
.card-body + & {
border-top: var(--#{$prefix}border-width) var(--#{$prefix}border-style) var(--#{$prefix}table-border-color);
}
}
@@ -541,7 +551,7 @@ Card avatar
Card list group
*/
.card-list-group {
.card-body+& {
.card-body + & {
border-top: var(--#{$prefix}border-width) var(--#{$prefix}border-style) var(--#{$prefix}border-color);
}
@@ -600,7 +610,7 @@ Card list group
}
}
+.nav-item {
+ .nav-item {
margin-inline-start: calc(-1 * #{$card-border-width});
}
}
@@ -640,7 +650,7 @@ Card list group
border-end-start-radius: 0;
}
.nav-tabs+.tab-content .card {
.nav-tabs + .tab-content .card {
border-end-start-radius: var(--#{$prefix}card-border-radius);
border-start-start-radius: 0;
}
@@ -654,7 +664,6 @@ Card note
--#{$prefix}card-border-color: #fff1c9;
}
/**
Card gradient
*/
@@ -663,10 +672,10 @@ Card gradient
--#{$prefix}card-gradient-opacity: 86%;
--#{$prefix}card-gradient: var(--tblr-primary), var(--tblr-primary);
background: radial-gradient(ellipse at center, var(--#{$prefix}card-bg) 0%, color-mix(in srgb, var(--#{$prefix}card-bg) 0%, transparent) 80%) border-box,
linear-gradient(var(--#{$prefix}card-gradient-direction), color-mix(in srgb, var(--#{$prefix}card-bg) var(--#{$prefix}card-gradient-opacity), transparent) 0%, var(--#{$prefix}card-bg) 40%) border-box,
linear-gradient(calc(270deg + var(--#{$prefix}card-gradient-direction)), var(--#{$prefix}card-gradient)) border-box;
background:
radial-gradient(ellipse at center, var(--#{$prefix}card-bg) 0%, color-mix(in srgb, var(--#{$prefix}card-bg) 0%, transparent) 80%) border-box,
linear-gradient(var(--#{$prefix}card-gradient-direction), color-mix(in srgb, var(--#{$prefix}card-bg) var(--#{$prefix}card-gradient-opacity), transparent) 0%, var(--#{$prefix}card-bg) 40%) border-box,
linear-gradient(calc(270deg + var(--#{$prefix}card-gradient-direction)), var(--#{$prefix}card-gradient)) border-box;
}
@each $name, $color in map.merge($colors, $theme-colors) {
@@ -676,14 +685,7 @@ Card gradient
}
.card-gradient-rainbow {
--#{$prefix}card-gradient: #78C5D6,
#459BA8,
#79C267,
#C5D647,
#F5D63D,
#F08B33,
#E868A2,
#BE61A5;
--#{$prefix}card-gradient: #78c5d6, #459ba8, #79c267, #c5d647, #f5d63d, #f08b33, #e868a2, #be61a5;
}
.card-gradient-sun {
@@ -695,7 +697,7 @@ Card gradient
}
.card-gradient-ocean {
--#{$prefix}card-gradient: #1CB5E0, #000851;
--#{$prefix}card-gradient: #1cb5e0, #000851;
}
.card-gradient-mellow {
@@ -703,7 +705,7 @@ Card gradient
}
.card-gradient-disco {
--#{$prefix}card-gradient: #FC466B, #3F5EFB;
--#{$prefix}card-gradient: #fc466b, #3f5efb;
}
.card-gradient-psychedelic {
@@ -715,7 +717,7 @@ Card gradient
}
.card-gradient-gold {
--#{$prefix}card-gradient: #9d4100, #bf7122, #f59f00, #FFD700;
--#{$prefix}card-gradient: #9d4100, #bf7122, #f59f00, #ffd700;
}
.card-gradient-animated {
@@ -732,4 +734,4 @@ Card gradient
.card-gradient-start {
--#{$prefix}card-gradient-direction: 90deg;
}
}

View File

@@ -74,7 +74,7 @@
content: '';
position: absolute;
top: -0.25rem;
inset-inline-start: 0.75rem;
inset-inline-start: 0.75rem;
display: block;
background: inherit;
width: 14px;
@@ -90,8 +90,8 @@
&.dropdown-menu-end {
&:before {
inset-inline-end: 0.75rem;
inset-inline-start: auto;
inset-inline-end: 0.75rem;
inset-inline-start: auto;
}
}
}

View File

@@ -9,7 +9,7 @@
&:after {
position: absolute;
top: 0;
inset-inline-start: 0;
inset-inline-start: 0;
width: 100%;
height: 100%;
content: '';
@@ -33,8 +33,8 @@ Dimmer
.loader {
position: absolute;
top: 50%;
inset-inline-end: 0;
inset-inline-start: 0;
inset-inline-end: 0;
inset-inline-start: 0;
display: none;
margin: 0 auto;
transform: translateY(-50%);

View File

@@ -3,7 +3,7 @@
> .btn-close {
position: absolute;
top: 0;
inset-inline-end: 0;
inset-inline-end: 0;
width: $modal-header-height;
height: $modal-header-height;
margin: 0;

View File

@@ -1,13 +1,13 @@
@keyframes progress-indeterminate {
0% {
inset-inline-end: 100%;
inset-inline-start: -35%;
inset-inline-end: 100%;
inset-inline-start: -35%;
}
100%,
60% {
inset-inline-end: -90%;
inset-inline-start: 100%;
inset-inline-end: -90%;
inset-inline-start: 100%;
}
}
@@ -64,7 +64,7 @@ Progress bar
position: absolute;
top: 0;
bottom: 0;
inset-inline-start: 0;
inset-inline-start: 0;
content: '';
background-color: inherit;
will-change: left, right;
@@ -93,6 +93,8 @@ Progressbg
.progressbg-text {
position: relative;
z-index: 1;
display: flex;
align-items: center;
@include text-truncate;
}

View File

@@ -25,7 +25,7 @@
&:before {
position: absolute;
inset-inline-end: 0;
inset-inline-end: 0;
bottom: 100%;
width: 0;
height: 0;
@@ -44,7 +44,7 @@
}
&.bg-#{$color}-lt {
border-color: rgba(var(--#{$prefix}#{$color}-rgb), 0.1) !important;
border-color: color-transparent(var(--#{$prefix}#{$color}), 0.1) !important;
}
}
}
@@ -65,7 +65,7 @@
&:before {
top: 0;
inset-inline-end: 100%;
inset-inline-end: 100%;
bottom: auto;
border-color: inherit;
border-top-color: transparent;
@@ -73,13 +73,13 @@
}
&.ribbon-start {
inset-inline-end: auto;
inset-inline-start: 0.75rem;
inset-inline-end: auto;
inset-inline-start: 0.75rem;
&:before {
top: 0;
inset-inline-end: 100%;
inset-inline-start: auto;
inset-inline-end: 100%;
inset-inline-start: auto;
}
}
}
@@ -92,7 +92,7 @@
&:before {
top: auto;
bottom: 100%;
inset-inline-start: 0;
inset-inline-start: 0;
border-color: inherit;
border-top-color: transparent;
border-inline-start-color: transparent;
@@ -111,7 +111,7 @@
&:after {
position: absolute;
top: 0;
inset-inline-end: 100%;
inset-inline-end: 100%;
display: block;
width: 0;
height: 0;
@@ -127,8 +127,8 @@
padding-inline-end: 0.5rem;
&:after {
inset-inline-end: auto;
inset-inline-start: 100%;
inset-inline-end: auto;
inset-inline-start: 100%;
border-inline-end-color: transparent;
border-inline-end-width: 0.5rem;
@@ -144,8 +144,8 @@
&:after {
top: 100%;
inset-inline-end: 0;
inset-inline-start: 0;
inset-inline-end: 0;
inset-inline-start: 0;
border-color: inherit;
border-width: 1rem;
border-top-width: 0;

View File

@@ -52,7 +52,7 @@
padding: 0.25rem 0.75rem;
gap: 0.5rem;
color: var(--#{$prefix}status-color);
background: rgba(var(--#{$prefix}status-color-rgb), 0.1);
background: color-transparent(var(--#{$prefix}status-color), 0.1);
font-size: $font-size-base;
text-transform: none;
letter-spacing: normal;

View File

@@ -52,7 +52,7 @@
&:not(:last-child):after {
position: absolute;
inset-inline-start: 50%;
inset-inline-start: 50%;
width: 100%;
content: '';
transform: translateY(-50%);
@@ -67,7 +67,7 @@
content: '';
position: absolute;
top: 0;
inset-inline-start: 50%;
inset-inline-start: 50%;
z-index: 1;
box-sizing: content-box;
display: flex;
@@ -137,7 +137,7 @@
&:before {
top: var(--#{$prefix}steps-dot-offset);
inset-inline-start: 0;
inset-inline-start: 0;
transform: translate(0, 0);
}
@@ -147,7 +147,7 @@
content: '';
transform: translateX(-50%);
top: var(--#{$prefix}steps-dot-offset);
inset-inline-start: calc(var(--#{$prefix}steps-dot-size) * 0.5);
inset-inline-start: calc(var(--#{$prefix}steps-dot-size) * 0.5);
width: var(--#{$prefix}steps-border-width);
height: calc(100% + 1rem);
}

View File

@@ -39,7 +39,7 @@
.switch-icon-b {
position: absolute;
top: 0;
inset-inline-start: 0;
inset-inline-start: 0;
opacity: 0;
}

View File

@@ -21,7 +21,7 @@
content: '';
position: absolute;
top: var(--#{$prefix}timeline-icon-size);
inset-inline-start: calc(var(--#{$prefix}timeline-icon-size) / 2);
inset-inline-start: calc(var(--#{$prefix}timeline-icon-size) / 2);
bottom: calc(-1 * var(--#{$prefix}page-padding));
width: var(--#{$prefix}border-width);
background-color: var(--#{$prefix}border-color);

View File

@@ -82,4 +82,4 @@ Form switch
.form-check-label {
padding-top: 0.125rem;
}
}
}

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