Fix the bugs found in the pre-1.6 review of dev (#3112)

This commit is contained in:
Paweł Kuna authored and GitHub committed 2026-09-21 18:20:16 +02:00
1 parent d9dc168665
commit 7bfeb12ab7
55 files changed
+728 -106

No files matched your search

+1
View File
@@ -30,6 +30,7 @@ Task-specific instructions live in `.agents/skills/<name>/SKILL.md` — read the
| `shared-lib` | moving logic into `shared/lib/` and testing it | | `shared-lib` | moving logic into `shared/lib/` and testing it |
| `core-scss` | writing or changing styles in `core/scss/` | | `core-scss` | writing or changing styles in `core/scss/` |
| `core-js` | working on the framework JavaScript in `core/js/` | | `core-js` | working on the framework JavaScript in `core/js/` |
| `backward-compat` | renaming, removing or changing anything in `core/` a user can touch — minor releases must not break projects |
| `bootstrap-component` | writing a new component class in `core/js/src/bootstrap/` (typed config, events, Data API) | | `bootstrap-component` | writing a new component class in `core/js/src/bootstrap/` (typed config, events, Data API) |
| `demo-pages` | adding or updating a page in `preview/pages/` | | `demo-pages` | adding or updating a page in `preview/pages/` |
| `write-docs` | writing or updating a page in `docs/content/` | | `write-docs` | writing or updating a page in `docs/content/` |
+104
View File
@@ -0,0 +1,104 @@
---
name: backward-compat
description: >-
Keep a change to `@tabler/core` backward compatible, so a patch or minor release never breaks a project that updates. Use before renaming, removing or changing the meaning of anything a user can touch — a class, a `--tblr-*` custom property, a Sass variable, mixin or function, a `data-*` attribute or option, a JS export, an event, a file under `dist/`, a default value — and whenever a changeset on `dev` looks like it needs a `major` bump. Also consult it proactively when a cleanup or refactor in `core/` deletes something, since "dead" code is often somebody's API. Covers what counts as public API, the deprecated-alias patterns, how to diff the public surface against the last release, and what to do when a break cannot be avoided.
---
# Backward compatibility
The README promises [Semantic Versioning](https://semver.org/): breaking changes only land in major releases. Users install `^1.0.0` and expect `npm update` to be safe. Issue #3108 is what happens when it is not.
So on `dev`, which ships patch and minor releases, **every change is additive or has an alias**. A change that cannot be made compatible does not go to `dev`. It waits for the next major.
## 1. What is public API
Anything a user can write in their own project. If it is in the list, it cannot be removed, renamed or given a new meaning in a minor release.
| Surface | Examples | Breaks when |
| --- | --- | --- |
| Class names | `.legend`, `.form-hint`, `.card-options` | removed, renamed, or the same name starts to mean something else |
| Custom properties | `--tblr-primary-rgb`, `--tblr-btn-focus-box-shadow` | no longer emitted, or no longer read by the component |
| Sass variables, maps and keys | `$focus-ring-blur`, a key of `$form-validation-states` | removed, renamed, or the expected type changes (a color becomes a `var()`) |
| Sass mixins and functions | `focus-ring($show-border)` | removed, a parameter renamed or reordered, a positional argument changes meaning |
| Data attributes and their options | `data-countup='{…}'`, `data-bs-toggle="…"` | removed, renamed, an option dropped, or a value that used to be accepted now throws |
| JS exports and globals | `tabler.Modal`, `window.tabler.*`, named exports, published types | removed, renamed, a method signature changed |
| Events | `change.bs.switch-icon` | renamed, no longer fired, fired at a different moment |
| Files in the package | `dist/libs/litepicker/…`, `dist/css/tabler.rtl.css` | the path 404s after an update |
| Dependencies | `dependencies`, `peerDependencies` | a user must install or uninstall something for the update to work |
| Defaults | the gray scale, `forceFallback`, the color mode | an untouched project looks or behaves differently in a way the user did not ask for |
| Browser support | `.browserslistrc` | the minimum version of any browser goes up |
Not public: anything under `preview/`, `docs/`, `shared/`, `.build/`, file layout inside `core/scss/` that is not reachable through `@use`, private `_underscore` members of JS classes, and the exact bytes of the compiled CSS.
Bug fixes are fine even when they change rendering, as long as the old behaviour was clearly wrong (a knob that did not move in RTL). When a fix changes how a correct page looks, treat it as a default change.
## 2. How to make the change compatible
### Rename
Ship both names. The old one becomes an alias that nobody has to touch.
- **Class**: add the alias to `core/scss/_extends.scss` with `@extend`, next to `.form-hint`. Start the comment with `` `<old>` is deprecated, use `<new>` instead ``.
- **Custom property**: keep reading the old name as a fallback — `var(--new, var(--old, <default>))` — or keep emitting the old one with the same value.
- **Sass variable**: keep the old variable and default the new one to it: `$new: $old !default;`. For a map key, accept both keys.
- **Mixin or function parameter**: add new parameters at the end, with defaults. Never rename a keyword argument; add a second one and map the old onto it.
- **Data attribute, option, JS export, event**: accept or fire both. The `data-bs-*` / `data-tblr-*` pair in `core/js/src/bootstrap/dom/manipulator.ts` is the pattern.
### Remove
Do not. Mark it deprecated, stop using it in Tabler's own code, keep it working, and list it for removal in the next major. This includes things that look dead: a declaration with no effect inside Tabler may still be what a user overrides.
### Change what a name means
Do not reuse the name. `.legend` was a dot in 1.5 and became a whole legend item in 1.6, so old markup rendered as nothing. Either give the new thing a new name, or keep the old use working — `.legend:empty` in `_extends.scss` still draws the dot.
### Add a name
Adding is safe, with one trap: a name that fits an existing pattern may already sit in user markup as a no-op. `.text-gray-200` did nothing in 1.4 and turned text light gray in 1.5. When a new utility completes a series (`.text-*`, `.bg-*`, `.border-*`), say so under "Visual changes" in the upgrade guide.
### Change a default
Keep the old default and make the new behaviour opt-in: a class, an attribute, an option or a Sass variable. The new default can flip in the next major.
### Drop a bundled library or a file
Keep the file in `dist/` and the entry in `core/libs.json` until the next major, even if Tabler's own pages stop using it. Replacing a library with built-in code is fine, as long as the old script tag still loads and the old global still works or is harmless.
### Validate input more strictly
New validation must not throw on input that used to work. Coerce the value, or log with `console.warn` and fall back to the default. A throw during page load stops all of `tabler.js`.
## 3. Check the public surface before the PR
Build core, then diff the compiled CSS against the last release. Anything in the output is a removed class or custom property and needs an alias or a revert.
```shell
last=$(npm view @tabler/core version)
curl -s "https://cdn.jsdelivr.net/npm/@tabler/core@$last/dist/css/tabler.css" -o /tmp/released.css
surface() { grep -oE "$1" "$2" | sort -u; }
class='\.[a-zA-Z][a-zA-Z0-9_-]*'
prop='--tblr-[a-zA-Z0-9_-]+'
# classes, then custom properties, that the release has and the build lost
comm -23 <(surface "$class" /tmp/released.css) <(surface "$class" core/dist/css/tabler.css)
comm -23 <(surface -e"$prop" /tmp/released.css) <(surface -e"$prop" core/dist/css/tabler.css)
```
For the rest, read the diff against the release tag with the table from section 1 in hand:
```shell
git diff "@tabler/core@$last"..HEAD -- core/scss/_variables.scss core/scss/mixins core/js core/libs.json core/package.json .browserslistrc
```
Look for deleted `$variables`, changed mixin signatures, removed exports in `core/js/tabler.ts`, removed `libs.json` entries and new or removed dependencies.
## 4. Changesets and the upgrade guide
- A changeset on `dev` is `patch` or `minor`. If the honest bump is `major`, the change is on the wrong branch — go back to section 2.
- Describe a deprecation as one, for example ``Added `.legend-dot`; an empty `.legend` still works but is deprecated.`` Never write `Removed` for something a user could have used.
- In the upgrade guide a deprecation goes under a "renamed" heading with "the old name still works" (see the `upgrade-guide` skill). A minor release should have nothing under a breaking-change heading.
## 5. When a break cannot be avoided
Some changes have no alias: dropping a whole color model, raising the browser floor, removing a dependency users import. Those belong to the next major release, not to `dev`. Say so in the PR, and do not split the change to sneak half of it into a minor. If you are unsure whether something is a break, assume it is and ask.
+1
View File
@@ -87,6 +87,7 @@ Prefer a small dependency-free implementation over pulling a package in: every k
- [ ] New module imported from `js/tabler.ts` (or deliberately kept out of the bundle) - [ ] New module imported from `js/tabler.ts` (or deliberately kept out of the bundle)
- [ ] `data-bs-*` attribute API unchanged; accessible state updated with the class - [ ] `data-bs-*` attribute API unchanged; accessible state updated with the class
- [ ] No export, option or event removed or renamed, and no new throw on input that used to work (`backward-compat`)
- [ ] Bootstrap-port changes stay close to upstream and come with a `js/tests/unit` spec - [ ] Bootstrap-port changes stay close to upstream and come with a `js/tests/unit` spec
- [ ] `js-docs-*` markers intact and self-contained - [ ] `js-docs-*` markers intact and self-contained
- [ ] `pnpm --filter @tabler/core test` (js + scss) clean - [ ] `pnpm --filter @tabler/core test` (js + scss) clean
+1
View File
@@ -118,6 +118,7 @@ pnpm run bundlewatch # size budgets (tabler.css 80 kB, tab
- [ ] Custom properties written bare; new foreign names added to `cssVarIgnore` - [ ] Custom properties written bare; new foreign names added to `cssVarIgnore`
- [ ] Dark mode via `light-dark()` or all three dark selectors, behind `$enable-dark-mode` - [ ] Dark mode via `light-dark()` or all three dark selectors, behind `$enable-dark-mode`
- [ ] Logical properties, or `--dir` + `/* rtl:ignore */`; no hand-written RTL - [ ] Logical properties, or `--dir` + `/* rtl:ignore */`; no hand-written RTL
- [ ] No class, custom property, Sass variable or mixin parameter removed or renamed without an alias (`backward-compat`)
- [ ] sass-true test for a mixin that can break silently - [ ] sass-true test for a mixin that can break silently
- [ ] `lint:scss`, `check:tokens`, `lint:prettier`, `test:scss` clean - [ ] `lint:scss`, `check:tokens`, `lint:prettier`, `test:scss` clean
- [ ] Docs page and class table updated (`write-docs`, `class-reference`), changeset written - [ ] Docs page and class table updated (`write-docs`, `class-reference`), changeset written
+1 -1
View File
@@ -39,7 +39,7 @@ Include **every affected package** in the frontmatter. Omit packages with no rel
|-------|------| |-------|------|
| **patch** | Bug fixes, small improvements, style/accessibility tweaks, variable/token fixes, doc typo/format fixes | | **patch** | Bug fixes, small improvements, style/accessibility tweaks, variable/token fixes, doc typo/format fixes |
| **minor** | New components, new pages, new CSS classes/utilities, significant enhancements, new preview demos | | **minor** | New components, new pages, new CSS classes/utilities, significant enhancements, new preview demos |
| **major** | Breaking changes, removed APIs/classes, rewrites that break consumers (rare in this repo) | | **major** | Breaking changes, removed APIs/classes, rewrites that break consumers. Never on `dev`: if the honest bump is `major`, stop and follow the `backward-compat` skill |
Packages can differ: e.g. `@tabler/core`: minor + `@tabler/docs`: patch is valid. Packages can differ: e.g. `@tabler/core`: minor + `@tabler/docs`: patch is valid.
+2 -1
View File
@@ -1,9 +1,10 @@
# Tabler's CSS uses these four with no fallback, so the floor is the first # Tabler's CSS uses these five with no fallback, so the floor is the first
# release that supports all of them: # release that supports all of them:
# light-dark() Chrome 123, Firefox 120, Safari 17.5 # light-dark() Chrome 123, Firefox 120, Safari 17.5
# @property Chrome 85, Firefox 128, Safari 16.4 # @property Chrome 85, Firefox 128, Safari 16.4
# :has() Chrome 105, Firefox 121, Safari 15.4 # :has() Chrome 105, Firefox 121, Safari 15.4
# color-mix() Chrome 111, Firefox 113, Safari 16.2 # color-mix() Chrome 111, Firefox 113, Safari 16.2
# oklch(from …) Chrome 122, Firefox 128, Safari 16.4
# Opera 109 and Samsung Internet 27 are the first releases built on Chromium 123. # Opera 109 and Samsung Internet 27 are the first releases built on Chromium 123.
Chrome >= 123 Chrome >= 123
Edge >= 123 Edge >= 123
+1 -1
View File
@@ -4,4 +4,4 @@
"@tabler/preview": patch "@tabler/preview": patch
--- ---
Added `Autosize` component to `tabler.js` with the `autosize` plugin bundled, plus `update()` and `dispose()` methods. Added `Autosize` component to `tabler.js`, with `update()` and `dispose()` methods.
@@ -0,0 +1,5 @@
---
"@tabler/docs": patch
---
Added relative colors, `oklch(from …)`, to the features that set the minimum browser versions on the browser support page.
+6
View File
@@ -0,0 +1,6 @@
---
"@tabler/docs": minor
"@tabler/preview": patch
---
Added a Sortable docs page covering `data-sortable`, the drag styles and the `Sortable` component.
+5
View File
@@ -0,0 +1,5 @@
---
"@tabler/docs": patch
---
Added a note to the 1.5 upgrade guide that `.text-gray-*` classes, a no-op in 1.4, now set the text color.
@@ -0,0 +1,5 @@
---
"@tabler/docs": patch
---
Added Litepicker, `.legend` and neutral gray sections to the 1.6 upgrade guide; fixed the jsVectorMap CDN link.
+5
View File
@@ -0,0 +1,5 @@
---
"@tabler/core": patch
---
Fixed `.alert-link` changing color on hover.
+5
View File
@@ -0,0 +1,5 @@
---
"@tabler/core": patch
---
Fixed `.card-options` being taken out of the `.card-header` flow by `position: absolute`.
@@ -0,0 +1,5 @@
---
"@tabler/core": patch
---
Fixed `Datepicker` reopening on a second trigger click, its `show` and `hide` events, and its color scheme on light pages.
+5
View File
@@ -0,0 +1,5 @@
---
"@tabler/core": patch
---
Fixed `--tblr-focus-ring-width` and `--tblr-focus-ring-color` overrides having no effect when set on a component.
@@ -0,0 +1,5 @@
---
"@tabler/core": patch
---
Fixed the `.form-switch` knob not moving in RTL and its size in `.form-switch-lg`.
@@ -0,0 +1,5 @@
---
"@tabler/core": patch
---
Fixed `tabler.js` stopping on page load when one element has an invalid `data-*` config; the error is logged instead.
+5
View File
@@ -0,0 +1,5 @@
---
"@tabler/core": patch
---
Fixed missing `IMaskInstance` and `SortableInstance` in `dist/types`; `vanilla-calendar-pro` is an optional peer dependency.
+1 -1
View File
@@ -4,4 +4,4 @@
"@tabler/docs": minor "@tabler/docs": minor
--- ---
Added `.legend` component with `.legend-dot`, `.legend-value`, `.legend-off` and `.legend-list`, plus `Legend` Astro component. Changed `.legend` from a colored dot into a legend item with `.legend-dot`, `.legend-value`, `.legend-off` and `.legend-list`, plus `Legend` Astro component. An empty `.legend` still renders as a dot, but is deprecated in favor of `.legend-dot`.
+1 -1
View File
@@ -4,4 +4,4 @@
"@tabler/docs": patch "@tabler/docs": patch
--- ---
Added a `switch-icon-loading` state and a `tabler:switch-icon-toggle` event that waits for a promise before switching. Added a `switch-icon-loading` state and a cancelable `toggle.bs.switch-icon` event whose `event.wait(promise)` holds the switch until the promise settles.
+1 -1
View File
@@ -3,4 +3,4 @@
"@tabler/docs": patch "@tabler/docs": patch
--- ---
Added `SwitchIcon`, `CountUp`, `InputMask` and `Sortable` components to `tabler.js`, with countUp.js bundled. Added `SwitchIcon`, `CountUp`, `InputMask` and `Sortable` components to `tabler.js`.
+2 -4
View File
@@ -7,7 +7,7 @@
import BaseComponent from './bootstrap/base-component' import BaseComponent from './bootstrap/base-component'
import EventHandler from './bootstrap/dom/event-handler' import EventHandler from './bootstrap/dom/event-handler'
import SelectorEngine from './bootstrap/dom/selector-engine' import { initAll } from './bootstrap/util/component-functions'
import type { ElementSelector } from './bootstrap/types' import type { ElementSelector } from './bootstrap/types'
type ComponentConfig = Record<string, never> type ComponentConfig = Record<string, never>
@@ -134,9 +134,7 @@ class Autosize extends BaseComponent {
*/ */
// js-docs-start autosize-init // js-docs-start autosize-init
for (const element of SelectorEngine.find(SELECTOR_DATA_TOGGLE)) { initAll(SELECTOR_DATA_TOGGLE, Autosize)
Autosize.getOrCreateInstance(element)
}
// js-docs-end autosize-init // js-docs-end autosize-init
export default Autosize export default Autosize
@@ -86,4 +86,23 @@ const eventActionOnPlugin = (Plugin: PluginComponent, onEvent: string, stringSel
}) })
} }
export { enableDismissTrigger, eventAction, eventActionOnPlugin } /**
* Creates the component for every element that matches on page load. One
* element with a broken config must not stop the rest of the bundle, so the
* error is logged and the loop goes on.
*/
const initAll = (selector: string, Plugin: PluginComponent, filter: ((element: HTMLElement) => boolean) | null = null): void => {
for (const element of SelectorEngine.find(selector)) {
if (filter && !filter(element)) {
continue
}
try {
Plugin.getOrCreateInstance(element)
} catch (error) {
console.error(error)
}
}
}
export { enableDismissTrigger, eventAction, eventActionOnPlugin, initAll }
+2 -3
View File
@@ -8,6 +8,7 @@
import BaseComponent from './bootstrap/base-component' import BaseComponent from './bootstrap/base-component'
import EventHandler from './bootstrap/dom/event-handler' import EventHandler from './bootstrap/dom/event-handler'
import SelectorEngine from './bootstrap/dom/selector-engine' import SelectorEngine from './bootstrap/dom/selector-engine'
import { initAll } from './bootstrap/util/component-functions'
import type { ElementSelector } from './bootstrap/types' import type { ElementSelector } from './bootstrap/types'
type ComponentConfig = { type ComponentConfig = {
@@ -190,9 +191,7 @@ class Clipboard extends BaseComponent {
*/ */
// js-docs-start clipboard-init // js-docs-start clipboard-init
for (const element of SelectorEngine.find(SELECTOR_DATA_TOGGLE)) { initAll(SELECTOR_DATA_TOGGLE, Clipboard)
Clipboard.getOrCreateInstance(element)
}
// js-docs-end clipboard-init // js-docs-end clipboard-init
export default Clipboard export default Clipboard
+2 -4
View File
@@ -7,7 +7,7 @@
import BaseComponent from './bootstrap/base-component' import BaseComponent from './bootstrap/base-component'
import EventHandler from './bootstrap/dom/event-handler' import EventHandler from './bootstrap/dom/event-handler'
import SelectorEngine from './bootstrap/dom/selector-engine' import { initAll } from './bootstrap/util/component-functions'
import type { ElementSelector } from './bootstrap/types' import type { ElementSelector } from './bootstrap/types'
type CountUpFormat = 'number' | 'time' type CountUpFormat = 'number' | 'time'
@@ -312,9 +312,7 @@ class CountUp extends BaseComponent {
*/ */
// js-docs-start countup-init // js-docs-start countup-init
for (const element of SelectorEngine.find(SELECTOR_DATA_COUNTUP)) { initAll(SELECTOR_DATA_COUNTUP, CountUp)
CountUp.getOrCreateInstance(element)
}
// js-docs-end countup-init // js-docs-end countup-init
export default CountUp export default CountUp
+97 -24
View File
@@ -9,6 +9,7 @@ import type { Calendar, DateAny, DateMode, DatesArr, MonthsCount, Options, Posit
import BaseComponent from './bootstrap/base-component' import BaseComponent from './bootstrap/base-component'
import EventHandler from './bootstrap/dom/event-handler' import EventHandler from './bootstrap/dom/event-handler'
import SelectorEngine from './bootstrap/dom/selector-engine' import SelectorEngine from './bootstrap/dom/selector-engine'
import { initAll } from './bootstrap/util/component-functions'
import { isDisabled } from './bootstrap/util/index' import { isDisabled } from './bootstrap/util/index'
import type { ElementSelector } from './bootstrap/types' import type { ElementSelector } from './bootstrap/types'
@@ -106,6 +107,13 @@ const DefaultType: Record<keyof ComponentConfig, string> = {
vcpOptions: 'object', vcpOptions: 'object',
} }
// Delegated Data API handlers run in the capture phase, before the plugin's
// own click listener, which opens the popup from a `setTimeout`. Two nested
// timeouts are the first point that is after it for certain.
const afterPluginTimers = (callback: () => void): void => {
setTimeout(() => setTimeout(callback))
}
/** /**
* Class definition * Class definition
* *
@@ -120,6 +128,14 @@ class Datepicker extends BaseComponent {
declare _config: ComponentConfig declare _config: ComponentConfig
_calendar: Calendar | null = null _calendar: Calendar | null = null
_isShown = false _isShown = false
// The plugin opens and closes the popup on its own too (its click and focus
// listeners, Escape, a click outside), so `onShow` and `onHide` are what
// tracks the state and fires the events; these flags tell them who asked.
_isShowing = false
_isHiding = false
_isSilent = false
_skipPluginShow = false
_resolveShown: (() => void) | null = null
_isInput = false _isInput = false
_isInline = false _isInline = false
_boundInput: HTMLInputElement | null = null _boundInput: HTMLInputElement | null = null
@@ -173,7 +189,9 @@ class Datepicker extends BaseComponent {
return // Inline calendars are always visible return // Inline calendars are always visible
} }
if (!this._calendar || isDisabled(this._element) || this._isShown) { // The first `calendar.show()` clicks the element to build the popup, and
// that click comes back here through the Data API; `_isShowing` stops it.
if (!this._calendar || isDisabled(this._element) || this._isShown || this._isShowing) {
return return
} }
@@ -182,10 +200,20 @@ class Datepicker extends BaseComponent {
return return
} }
this._isShowing = true
this._skipPluginShow = false
this._calendar.show() this._calendar.show()
this._isShown = true
EventHandler.trigger(this._element, EVENT_SHOWN) // The first show builds the popup and opens it a tick later
if (!this._isShown) {
await new Promise<void>((resolve) => {
this._resolveShown = resolve
afterPluginTimers(resolve)
})
}
this._resolveShown = null
this._isShowing = false
} }
async hide(): Promise<void> { async hide(): Promise<void> {
@@ -202,10 +230,16 @@ class Datepicker extends BaseComponent {
return return
} }
this._calendar.hide() // On a click on a button trigger the plugin's own listener runs next and
this._isShown = false // queues a `show()`; without this the popup would open again right away.
this._skipPluginShow = true
afterPluginTimers(() => {
this._skipPluginShow = false
})
EventHandler.trigger(this._element, EVENT_HIDDEN) this._isHiding = true
this._calendar.hide()
this._isHiding = false
} }
dispose(): void { dispose(): void {
@@ -419,6 +453,58 @@ class Datepicker extends BaseComponent {
EventHandler.on(document, EVENT_FOCUSIN, this._onFocusIn) EventHandler.on(document, EVENT_FOCUSIN, this._onFocusIn)
} }
_silently(callback: () => void): void {
this._isSilent = true
callback()
this._isSilent = false
}
_handlePluginShow(): void {
const calendar = this._calendar
if (!calendar) {
return
}
if (!this._isSilent && !this._isShown) {
// Opened by the plugin itself: right after `hide()` on a trigger click it
// must stay closed, otherwise it gets the same cancelable `show` event.
const prevented = this._skipPluginShow || (!this._isShowing && EventHandler.trigger(this._element, EVENT_SHOW)?.defaultPrevented)
if (prevented) {
this._silently(() => calendar.hide())
return
}
}
const wasShown = this._isShown
this._isShown = true
this._syncThemeAttribute(calendar.context.mainElement)
this._alignToPositionElement()
if (!wasShown) {
EventHandler.trigger(this._element, EVENT_SHOWN)
}
this._resolveShown?.()
}
_handlePluginHide(): void {
const calendar = this._calendar
if (!calendar || this._isSilent || !this._isShown) {
return
}
// Closed by the plugin itself (Escape, a click outside): `hide` is still
// cancelable, the popup opens again before the browser paints.
if (!this._isHiding && EventHandler.trigger(this._element, EVENT_HIDE)?.defaultPrevented) {
this._silently(() => calendar.show())
return
}
this._isShown = false
EventHandler.trigger(this._element, EVENT_HIDDEN)
}
_buildCalendarOptions(): Options { _buildCalendarOptions(): Options {
// The plugin uses 'system' for auto-detection, Bootstrap and Tabler use 'auto' // The plugin uses 'system' for auto-detection, Bootstrap and Tabler use 'auto'
const theme = this._getEffectiveTheme() const theme = this._getEffectiveTheme()
@@ -440,18 +526,8 @@ class Datepicker extends BaseComponent {
onInit: (self) => { onInit: (self) => {
this._syncThemeAttribute(self.context.mainElement) this._syncThemeAttribute(self.context.mainElement)
}, },
onShow: () => { onShow: () => this._handlePluginShow(),
if (!this._calendar) { onHide: () => this._handlePluginHide(),
return
}
this._isShown = true
this._syncThemeAttribute(this._calendar.context.mainElement)
this._alignToPositionElement()
},
onHide: () => {
this._isShown = false
},
} }
// Navigate to the month of the first selected date // Navigate to the month of the first selected date
@@ -584,14 +660,11 @@ EventHandler.on(document, EVENT_FOCUSIN_DATA_API, SELECTOR_DATA_TOGGLE, function
// Render on load what cannot wait for a focus or a click: inline calendars, // Render on load what cannot wait for a focus or a click: inline calendars,
// and fields with preselected dates or a value, so they show it right away. // and fields with preselected dates or a value, so they show it right away.
for (const element of SelectorEngine.find(SELECTOR_DATA_TOGGLE)) { initAll(SELECTOR_DATA_TOGGLE, Datepicker, (element) => {
const { dataset } = element const { dataset } = element
const eager = dataset.bsInline === 'true' || dataset.tblrInline === 'true' || 'bsSelectedDates' in dataset || 'tblrSelectedDates' in dataset || (element as HTMLInputElement).value
if (eager) { return dataset.bsInline === 'true' || dataset.tblrInline === 'true' || 'bsSelectedDates' in dataset || 'tblrSelectedDates' in dataset || Boolean((element as HTMLInputElement).value)
Datepicker.getOrCreateInstance(element) })
}
}
// js-docs-end datepicker-init // js-docs-end datepicker-init
export default Datepicker export default Datepicker
+6 -20
View File
@@ -1,23 +1,9 @@
// IMask (https://imask.js.org) is loaded separately, not bundled // Plugins that are loaded separately, not bundled: IMask (https://imask.js.org),
interface IMaskInstance { // SortableJS (https://sortablejs.github.io/Sortable/) and Vanilla Calendar Pro
value: string // (https://vanilla-calendar.pro). The instance types live in the component
unmaskedValue: string // modules, so they are part of the published `dist/types`.
updateValue(): void
updateOptions(options: Record<string, unknown>): void
destroy(): void
}
// SortableJS (https://sortablejs.github.io/Sortable/) is loaded separately, not bundled
interface SortableInstance {
option(name: string, value?: unknown): unknown
toArray(): string[]
sort(order: string[], useAnimation?: boolean): void
destroy(): void
}
interface Window { interface Window {
// Vanilla Calendar Pro (https://vanilla-calendar.pro) is loaded separately, not bundled
VanillaCalendarPro?: typeof import('vanilla-calendar-pro') VanillaCalendarPro?: typeof import('vanilla-calendar-pro')
IMask?: new (element: HTMLElement, options: { mask: string; lazy?: boolean }) => IMaskInstance IMask?: new (element: HTMLElement, options: { mask: string; lazy?: boolean }) => import('./input-mask').IMaskInstance
Sortable?: new (element: HTMLElement, options?: Record<string, unknown>) => SortableInstance Sortable?: new (element: HTMLElement, options?: Record<string, unknown>) => import('./sortable').SortableInstance
} }
+12 -4
View File
@@ -6,9 +6,18 @@
*/ */
import BaseComponent from './bootstrap/base-component' import BaseComponent from './bootstrap/base-component'
import SelectorEngine from './bootstrap/dom/selector-engine' import { initAll } from './bootstrap/util/component-functions'
import type { ElementSelector } from './bootstrap/types' import type { ElementSelector } from './bootstrap/types'
// The part of an IMask instance the component uses
interface IMaskInstance {
value: string
unmaskedValue: string
updateValue(): void
updateOptions(options: Record<string, unknown>): void
destroy(): void
}
type ComponentConfig = { type ComponentConfig = {
mask: string mask: string
lazy: boolean lazy: boolean
@@ -114,9 +123,8 @@ class InputMask extends BaseComponent {
*/ */
// js-docs-start input-mask-init // js-docs-start input-mask-init
for (const element of SelectorEngine.find(SELECTOR_DATA_MASK)) { initAll(SELECTOR_DATA_MASK, InputMask)
InputMask.getOrCreateInstance(element)
}
// js-docs-end input-mask-init // js-docs-end input-mask-init
export default InputMask export default InputMask
export type { IMaskInstance }
+2 -3
View File
@@ -8,6 +8,7 @@
import BaseComponent from './bootstrap/base-component' import BaseComponent from './bootstrap/base-component'
import EventHandler from './bootstrap/dom/event-handler' import EventHandler from './bootstrap/dom/event-handler'
import SelectorEngine from './bootstrap/dom/selector-engine' import SelectorEngine from './bootstrap/dom/selector-engine'
import { initAll } from './bootstrap/util/component-functions'
import type { ElementSelector } from './bootstrap/types' import type { ElementSelector } from './bootstrap/types'
type OtpInputType = 'numeric' | 'alphanumeric' | 'alpha' type OtpInputType = 'numeric' | 'alphanumeric' | 'alpha'
@@ -425,9 +426,7 @@ class OtpInput extends BaseComponent {
*/ */
// js-docs-start otp-init // js-docs-start otp-init
for (const element of SelectorEngine.find(SELECTOR_DATA_TOGGLE)) { initAll(SELECTOR_DATA_TOGGLE, OtpInput)
OtpInput.getOrCreateInstance(element)
}
// js-docs-end otp-init // js-docs-end otp-init
export default OtpInput export default OtpInput
+11 -4
View File
@@ -6,9 +6,17 @@
*/ */
import BaseComponent from './bootstrap/base-component' import BaseComponent from './bootstrap/base-component'
import SelectorEngine from './bootstrap/dom/selector-engine' import { initAll } from './bootstrap/util/component-functions'
import type { ElementSelector } from './bootstrap/types' import type { ElementSelector } from './bootstrap/types'
// The part of a SortableJS instance the component uses
interface SortableInstance {
option(name: string, value?: unknown): unknown
toArray(): string[]
sort(order: string[], useAnimation?: boolean): void
destroy(): void
}
type ComponentConfig = Record<string, unknown> type ComponentConfig = Record<string, unknown>
type ComponentConfigInput = Record<string, unknown> type ComponentConfigInput = Record<string, unknown>
@@ -108,9 +116,8 @@ class Sortable extends BaseComponent {
*/ */
// js-docs-start sortable-init // js-docs-start sortable-init
for (const element of SelectorEngine.find(SELECTOR_DATA_SORTABLE)) { initAll(SELECTOR_DATA_SORTABLE, Sortable)
Sortable.getOrCreateInstance(element)
}
// js-docs-end sortable-init // js-docs-end sortable-init
export default Sortable export default Sortable
export type { SortableInstance }
+2 -4
View File
@@ -7,7 +7,7 @@
import BaseComponent from './bootstrap/base-component' import BaseComponent from './bootstrap/base-component'
import EventHandler from './bootstrap/dom/event-handler' import EventHandler from './bootstrap/dom/event-handler'
import SelectorEngine from './bootstrap/dom/selector-engine' import { initAll } from './bootstrap/util/component-functions'
import type { ComponentConfig as BaseConfig, ElementSelector } from './bootstrap/types' import type { ComponentConfig as BaseConfig, ElementSelector } from './bootstrap/types'
type SparklineType = 'line' | 'bar' | 'circle' | 'tristate' type SparklineType = 'line' | 'bar' | 'circle' | 'tristate'
@@ -603,9 +603,7 @@ class Sparkline extends BaseComponent {
*/ */
// js-docs-start sparkline-init // js-docs-start sparkline-init
for (const element of SelectorEngine.find(SELECTOR_DATA_TOGGLE)) { initAll(SELECTOR_DATA_TOGGLE, Sparkline)
Sparkline.getOrCreateInstance(element)
}
// js-docs-end sparkline-init // js-docs-end sparkline-init
export default Sparkline export default Sparkline
+2 -3
View File
@@ -8,6 +8,7 @@
import BaseComponent from './bootstrap/base-component' import BaseComponent from './bootstrap/base-component'
import EventHandler from './bootstrap/dom/event-handler' import EventHandler from './bootstrap/dom/event-handler'
import SelectorEngine from './bootstrap/dom/selector-engine' import SelectorEngine from './bootstrap/dom/selector-engine'
import { initAll } from './bootstrap/util/component-functions'
import type { ElementSelector } from './bootstrap/types' import type { ElementSelector } from './bootstrap/types'
type StrengthLevel = 'weak' | 'fair' | 'good' | 'strong' type StrengthLevel = 'weak' | 'fair' | 'good' | 'strong'
@@ -270,9 +271,7 @@ class Strength extends BaseComponent {
*/ */
// js-docs-start strength-init // js-docs-start strength-init
for (const element of SelectorEngine.find(SELECTOR_DATA_STRENGTH)) { initAll(SELECTOR_DATA_STRENGTH, Strength)
Strength.getOrCreateInstance(element)
}
// js-docs-end strength-init // js-docs-end strength-init
export default Strength export default Strength
+2 -3
View File
@@ -8,6 +8,7 @@
import BaseComponent from './bootstrap/base-component' import BaseComponent from './bootstrap/base-component'
import EventHandler from './bootstrap/dom/event-handler' import EventHandler from './bootstrap/dom/event-handler'
import SelectorEngine from './bootstrap/dom/selector-engine' import SelectorEngine from './bootstrap/dom/selector-engine'
import { initAll } from './bootstrap/util/component-functions'
import type { ElementSelector } from './bootstrap/types' import type { ElementSelector } from './bootstrap/types'
type ComponentConfig = Record<string, never> type ComponentConfig = Record<string, never>
@@ -160,9 +161,7 @@ class SwitchIcon extends BaseComponent {
*/ */
// js-docs-start switch-icon-init // js-docs-start switch-icon-init
for (const element of SelectorEngine.find(SELECTOR_DATA_TOGGLE)) { initAll(SELECTOR_DATA_TOGGLE, SwitchIcon)
SwitchIcon.getOrCreateInstance(element)
}
// js-docs-end switch-icon-init // js-docs-end switch-icon-init
export default SwitchIcon export default SwitchIcon
+63
View File
@@ -27,6 +27,7 @@ describe('Datepicker', () => {
}) })
const input = (): HTMLInputElement => fixtureEl.querySelector('input')! const input = (): HTMLInputElement => fixtureEl.querySelector('input')!
const tick = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 20))
describe('NAME', () => { describe('NAME', () => {
it('should return plugin name', () => { it('should return plugin name', () => {
@@ -243,5 +244,67 @@ describe('Datepicker', () => {
expect(instance).not.toBeNull() expect(instance).not.toBeNull()
expect(instance!._isShown).toBe(true) expect(instance!._isShown).toBe(true)
}) })
it('should close on the second click of a button trigger', async () => {
fixtureEl.innerHTML = '<button type="button" data-bs-toggle="datepicker">Select date</button>'
const button = fixtureEl.querySelector('button')!
const seen: string[] = []
for (const name of ['show', 'shown', 'hide', 'hidden']) {
button.addEventListener(`${name}.bs.datepicker`, () => seen.push(name))
}
button.click()
await tick()
button.click()
await tick()
const instance = Datepicker.getInstance(button) as Datepicker
expect(instance._isShown).toBe(false)
expect(instance.calendar!.context.isShowInInputMode).toBe(false)
expect(seen).toEqual(['show', 'shown', 'hide', 'hidden'])
button.click()
await tick()
expect(instance._isShown).toBe(true)
})
})
describe('closed by the plugin', () => {
const pressEscape = (): void => {
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
}
it('should fire hide and hidden on Escape', async () => {
fixtureEl.innerHTML = '<input type="text" data-bs-toggle="datepicker">'
const seen: string[] = []
for (const name of ['hide', 'hidden']) {
input().addEventListener(`${name}.bs.datepicker`, () => seen.push(name))
}
const instance = new Datepicker(input())
await instance.show()
pressEscape()
expect(instance._isShown).toBe(false)
expect(seen).toEqual(['hide', 'hidden'])
})
it('should stay open when hide is prevented', async () => {
fixtureEl.innerHTML = '<input type="text" data-bs-toggle="datepicker">'
const seen: string[] = []
input().addEventListener('hide.bs.datepicker', (event) => event.preventDefault())
for (const name of ['shown', 'hidden']) {
input().addEventListener(`${name}.bs.datepicker`, () => seen.push(name))
}
const instance = new Datepicker(input())
await instance.show()
pressEscape()
expect(instance._isShown).toBe(true)
expect(instance.calendar!.context.isShowInInputMode).toBe(true)
expect(seen).toEqual(['shown'])
})
}) })
}) })
@@ -1,6 +1,6 @@
import { describe, it, expect, beforeAll, afterEach, vi } from 'vitest' import { describe, it, expect, beforeAll, afterEach, vi } from 'vitest'
import BaseComponent from '../../../src/bootstrap/base-component' import BaseComponent from '../../../src/bootstrap/base-component'
import { enableDismissTrigger, eventActionOnPlugin } from '../../../src/bootstrap/util/component-functions' import { enableDismissTrigger, eventActionOnPlugin, initAll } from '../../../src/bootstrap/util/component-functions'
import { clearFixture, createEvent, getFixture } from '../../helpers/fixture' import { clearFixture, createEvent, getFixture } from '../../helpers/fixture'
class DummyClass extends BaseComponent { class DummyClass extends BaseComponent {
@@ -145,4 +145,47 @@ describe('Component Functions', () => {
vi.restoreAllMocks() vi.restoreAllMocks()
}) })
}) })
describe('initAll', () => {
it('should create an instance for every matching element', () => {
fixtureEl.innerHTML = '<div class="init-all"></div><div class="init-all"></div>'
initAll('.init-all', DummyClass)
for (const element of fixtureEl.querySelectorAll<HTMLElement>('.init-all')) {
expect(DummyClass.getInstance(element)).toBeInstanceOf(DummyClass)
}
})
it('should skip the elements the filter rejects', () => {
fixtureEl.innerHTML = '<div class="init-all" id="skip"></div><div class="init-all" id="keep"></div>'
initAll('.init-all', DummyClass, (element) => element.id === 'keep')
expect(DummyClass.getInstance(fixtureEl.querySelector<HTMLElement>('#skip'))).toBeNull()
expect(DummyClass.getInstance(fixtureEl.querySelector<HTMLElement>('#keep'))).toBeInstanceOf(DummyClass)
})
it('should log the error and go on when one element throws', () => {
fixtureEl.innerHTML = '<div class="init-all" id="broken"></div><div class="init-all" id="fine"></div>'
const error = new TypeError('TEST: bad option')
const spyError = vi.spyOn(console, 'error').mockImplementation(() => {})
const Throwing = {
NAME: 'test',
getOrCreateInstance(element: HTMLElement | string | null) {
if ((element as HTMLElement).id === 'broken') {
throw error
}
return DummyClass.getOrCreateInstance(element as HTMLElement)
},
}
expect(() => initAll('.init-all', Throwing)).not.toThrow()
expect(spyError).toHaveBeenCalledWith(error)
expect(DummyClass.getInstance(fixtureEl.querySelector<HTMLElement>('#fine'))).toBeInstanceOf(DummyClass)
spyError.mockRestore()
})
})
}) })
+8
View File
@@ -245,6 +245,14 @@
"dependencies": { "dependencies": {
"@popperjs/core": "^2.11.8" "@popperjs/core": "^2.11.8"
}, },
"peerDependencies": {
"vanilla-calendar-pro": "^3.3.2"
},
"peerDependenciesMeta": {
"vanilla-calendar-pro": {
"optional": true
}
},
"devDependencies": { "devDependencies": {
"@melloware/coloris": "^0.25.0", "@melloware/coloris": "^0.25.0",
"@types/node": "^26.4.0", "@types/node": "^26.4.0",
+7
View File
@@ -111,3 +111,10 @@
// Kept from the old rule, so an inline element still gets the top margin. // Kept from the old rule, so an inline element still gets the top margin.
display: block; display: block;
} }
// Before 1.6 `.legend` was the dot itself and had no children. An empty
// `.legend` is deprecated, use `.legend-dot` instead (from `ui/_legend.scss`).
.legend:empty {
@extend .legend-dot;
padding: 0;
}
-1
View File
@@ -122,7 +122,6 @@
--focus-ring-opacity: #{$focus-ring-opacity}; --focus-ring-opacity: #{$focus-ring-opacity};
--focus-ring-opacity-dark: #{$focus-ring-opacity-dark}; --focus-ring-opacity-dark: #{$focus-ring-opacity-dark};
--focus-ring-color: #{$focus-ring-color}; --focus-ring-color: #{$focus-ring-color};
--focus-ring: var(--focus-ring-width) solid var(--focus-ring-color);
// scss-docs-end root-focus-variables // scss-docs-end root-focus-variables
// scss-docs-start root-form-validation-variables // scss-docs-start root-form-validation-variables
+2 -1
View File
@@ -145,7 +145,8 @@
// The knob replaces the masked check icon: a plain circle that slides. // The knob replaces the masked check icon: a plain circle that slides.
&::before { &::before {
inset: 0 auto 0 var(--form-switch-knob-offset); inset-block: 0;
inset-inline: var(--form-switch-knob-offset) auto;
width: var(--form-switch-knob-size); width: var(--form-switch-knob-size);
height: var(--form-switch-knob-size); height: var(--form-switch-knob-size);
margin-block: auto; margin-block: auto;
+5
View File
@@ -29,6 +29,11 @@
// Runtime counterpart of `adjust-lightness()`: relative color syntax, so the // Runtime counterpart of `adjust-lightness()`: relative color syntax, so the
// token follows `--tblr-*` overrides in the browser. Emit-only, like `mix-color-css()`. // token follows `--tblr-*` overrides in the browser. Emit-only, like `mix-color-css()`.
//
// Only `l` goes through `calc()`. Safari before 18 resolves `h` to an angle, so
// `calc(h + 30)` is invalid there and needs `calc(h + 30deg)`; `l` and `c` are
// numbers in every browser. The origin must be a plain color: Safari before 18
// does not take `currentcolor`, a system color or `light-dark()` there.
@function adjust-lightness-css($color, $amount) { @function adjust-lightness-css($color, $amount) {
$delta: math.div($amount, 100%); $delta: math.div($amount, 100%);
$sign: if(sass($delta < 0): '-'; else: '+'); $sign: if(sass($delta < 0): '-'; else: '+');
+4 -1
View File
@@ -66,11 +66,14 @@
gap: var(--list-gap); gap: var(--list-gap);
} }
// `--focus-ring` is not set by default: composed on `:root` it would freeze the
// width and the colour there, and an override on a component would not reach
// the ring. Set it to replace the whole outline at once.
@mixin focus-ring($offset: false, $color: null) { @mixin focus-ring($offset: false, $color: null) {
@if $color != null { @if $color != null {
outline: var(--focus-ring-width) solid #{$color}; outline: var(--focus-ring-width) solid #{$color};
} @else { } @else {
outline: var(--focus-ring); outline: var(--focus-ring, var(--focus-ring-width) solid var(--focus-ring-color));
} }
@if $offset { @if $offset {
outline-offset: var(--focus-ring-offset); outline-offset: var(--focus-ring-offset);
@@ -44,9 +44,11 @@ exports[`css custom-property prefixing > leaves vendor-owned names alone and pre
"--tblr-chart-5", "--tblr-chart-5",
"--tblr-danger", "--tblr-danger",
"--tblr-focus-ring", "--tblr-focus-ring",
"--tblr-focus-ring-color",
"--tblr-focus-ring-offset", "--tblr-focus-ring-offset",
"--tblr-focus-ring-opacity", "--tblr-focus-ring-opacity",
"--tblr-focus-ring-opacity-dark", "--tblr-focus-ring-opacity-dark",
"--tblr-focus-ring-width",
"--tblr-font-sans-serif", "--tblr-font-sans-serif",
"--tblr-font-size-h3", "--tblr-font-size-h3",
"--tblr-font-size-h5", "--tblr-font-size-h5",
+2 -2
View File
@@ -113,7 +113,7 @@
} }
@include expect() { @include expect() {
.t { .t {
outline: var(--focus-ring); outline: var(--focus-ring, var(--focus-ring-width) solid var(--focus-ring-color));
} }
} }
} }
@@ -128,7 +128,7 @@
} }
@include expect() { @include expect() {
.t { .t {
outline: var(--focus-ring); outline: var(--focus-ring, var(--focus-ring-width) solid var(--focus-ring-color));
outline-offset: var(--focus-ring-offset); outline-offset: var(--focus-ring-offset);
} }
} }
+4
View File
@@ -61,6 +61,10 @@
.alert-link { .alert-link {
font-weight: $alert-link-font-weight; font-weight: $alert-link-font-weight;
color: var(--alert-link-color); color: var(--alert-link-color);
&:hover {
color: var(--alert-link-color);
}
} }
.alert-dismissible { .alert-dismissible {
-3
View File
@@ -405,9 +405,6 @@ Card optinos
.card-options { .card-options {
--card-options-top: #{$card-options-top}; --card-options-top: #{$card-options-top};
--card-options-inset: #{$card-options-inset}; --card-options-inset: #{$card-options-inset};
position: absolute;
inset-inline-end: var(--card-options-inset);
top: var(--card-options-top);
display: flex; display: flex;
margin-inline-start: auto; margin-inline-start: auto;
} }
-9
View File
@@ -37,15 +37,6 @@
font-family: inherit; font-family: inherit;
font-size: var(--calendar-font-size); font-size: var(--calendar-font-size);
color: var(--calendar-color); color: var(--calendar-color);
color-scheme: light dark;
&[data-bs-theme='light'] {
color-scheme: light;
}
&[data-bs-theme='dark'] {
color-scheme: dark;
}
button { button {
font-family: inherit; font-family: inherit;
+2 -1
View File
@@ -93,10 +93,11 @@ Form switch
padding-inline-start: var(--form-switch-lg-padding-start); padding-inline-start: var(--form-switch-lg-padding-start);
.form-check-input { .form-check-input {
--form-switch-knob-size: calc(var(--form-switch-lg-height) * 0.75);
--form-switch-knob-offset: calc(var(--form-switch-lg-height) * 0.125);
width: var(--form-switch-lg-width); width: var(--form-switch-lg-width);
height: var(--form-switch-lg-height); height: var(--form-switch-lg-height);
margin-inline-start: var(--form-switch-lg-input-offset); margin-inline-start: var(--form-switch-lg-input-offset);
background-size: var(--form-switch-lg-bg-size);
} }
.form-check-label { .form-check-label {
@@ -39,9 +39,12 @@ Feature|Used for|Chrome|Firefox|Safari
`@property`|The animated gradient on cards|85|128|16.4 `@property`|The animated gradient on cards|85|128|16.4
`:has()`|State that depends on a child, for example a selected table row or the folded sidebar|105|121|15.4 `:has()`|State that depends on a child, for example a selected table row or the folded sidebar|105|121|15.4
`color-mix()`|Tints, shades and translucent surfaces|111|113|16.2 `color-mix()`|Tints, shades and translucent surfaces|111|113|16.2
Relative colors, `oklch(from …)`|The darker hover shade of every color and the dark mode borders|122|128|16.4
Opera 109 and Samsung Internet 27 are the first releases built on Chromium 123. Opera 109 and Samsung Internet 27 are the first releases built on Chromium 123.
Safari 16.4 to 17 follow an older draft of relative colors, where math on the hue needs a `deg` unit. Tabler only changes the lightness, which works the same in every version. If you override a Tabler color with `light-dark()`, its `-darken` shade needs Safari 18.
## Unsupported browsers ## Unsupported browsers
Internet Explorer is not supported, and neither is any browser released before the versions above. There is no polyfill to add: the features that set the floor are CSS, so a missing one cannot be patched with JavaScript. Internet Explorer is not supported, and neither is any browser released before the versions above. There is no polyfill to add: the features that set the floor are CSS, so a missing one cannot be patched with JavaScript.
@@ -27,6 +27,7 @@ Load `tabler-theme.js`|Pages follow the OS color scheme now - see [Color mode](#
Use payment provider icons|Check the [removed providers](#removed-payment-providers) Use payment provider icons|Check the [removed providers](#removed-payment-providers)
Link files inside `dist/libs`|Check the [moved paths](#smaller-distlibs-folder) Link files inside `dist/libs`|Check the [moved paths](#smaller-distlibs-folder)
Load `tabler.rtl.css`|You can drop it - plain `tabler.css` now handles RTL Load `tabler.rtl.css`|You can drop it - plain `tabler.css` now handles RTL
Have `.text-gray-*` classes in your markup|They color the text now - see [Gray text utilities](#gray-text-utilities)
## Update the package ## Update the package
@@ -277,6 +278,19 @@ These changes are not breaking, but they change how a page looks. Check your scr
- **Layout**: the `margin-inline-start: calc(100vw - 100%)` hack on the root element is gone, so there is no white gap on the left when a scrollbar is present. - **Layout**: the `margin-inline-start: calc(100vw - 100%)` hack on the root element is gone, so there is no white gap on the left when a scrollbar is present.
- **Trending**: the component uses the `arrow-up` and `arrow-down` icons instead of `trending-up` and `trending-down`. - **Trending**: the component uses the `arrow-up` and `arrow-down` icons instead of `trending-up` and `trending-down`.
### Gray text utilities
Tabler 1.4 had no `.text-gray-*` classes, only `.text-gray-*-fg`. A class like `.text-gray-200` in your markup did nothing, and the text kept the color of its parent. Tabler 1.5 adds `.text-gray-50` to `.text-gray-950`, so the same class now sets the color. On `.text-gray-200` that is a very light gray, which is hard to read on a white page.
Pick the shade you want to see, or remove the class:
```diff
- <p class="text-gray-200">Last updated 3 hours ago</p>
+ <p class="text-gray-600">Last updated 3 hours ago</p>
```
For muted text, `.text-secondary` follows the theme and keeps enough contrast in dark mode.
## RTL ## RTL
The Sass sources use logical properties and a `--tblr-dir` multiplier, so plain `tabler.css` works in both directions. Set `dir` on the document and drop the RTL stylesheet: The Sass sources use logical properties and a `--tblr-dir` multiplier, so plain `tabler.css` works in both directions. Set `dir` on the document and drop the RTL stylesheet:
@@ -7,7 +7,7 @@ order: 1
related: [/ui/getting-started/upgrade/1-5, /ui/getting-started/customize] related: [/ui/getting-started/upgrade/1-5, /ui/getting-started/customize]
--- ---
Tabler 1.6 is a much smaller step than 1.5 was. If you only load the compiled files from `dist/`, the upgrade usually takes a few minutes. There are three things that can bite you, and they all live in your own code, not in Tabler's: any `rgba(var(--tblr-*-rgb), …)` in your CSS, any call to the old `autosize()`, `countUp` or `ClipboardJS` globals, and any use of `tabler.tabler.getColor()`. Tabler 1.6 is a much smaller step than 1.5 was. If you only load the compiled files from `dist/`, the upgrade usually takes a few minutes. There are four things that can bite you, and they all live in your own code, not in Tabler's: any `rgba(var(--tblr-*-rgb), …)` in your CSS, any call to the old `autosize()`, `countUp`, `ClipboardJS` or `Litepicker` globals, any use of `tabler.tabler.getColor()`, and any `.legend` used as a colored dot.
Projects that compile Tabler from the Sass sources should also read [Sass changes](#sass-changes). Projects that compile Tabler from the Sass sources should also read [Sass changes](#sass-changes).
@@ -23,6 +23,10 @@ Use `--tblr-primary-rgb` or any `--tblr-*-rgb` variable|Replace it with `color-m
Load `dist/libs/autosize` or `dist/libs/countup.js`|Remove the script tags, both are part of `tabler.js` now Load `dist/libs/autosize` or `dist/libs/countup.js`|Remove the script tags, both are part of `tabler.js` now
Call `autosize(el)` or `new countUp.CountUp(...)` yourself|Switch to `tabler.Autosize` and `tabler.CountUp` Call `autosize(el)` or `new countUp.CountUp(...)` yourself|Switch to `tabler.Autosize` and `tabler.CountUp`
Load `dist/libs/clipboard` or call `new ClipboardJS(...)`|Switch to the `Clipboard` component, see [Clipboard](#clipboard-is-built-in) Load `dist/libs/clipboard` or call `new ClipboardJS(...)`|Switch to the `Clipboard` component, see [Clipboard](#clipboard-is-built-in)
Load `dist/libs/litepicker` or call `new Litepicker(...)`|Switch to the `Datepicker` plugin, see [Litepicker is replaced by Datepicker](#litepicker-is-replaced-by-datepicker)
Use `<span class="legend">` as a colored dot|Rename to `.legend-dot` (the old markup still works), see [Legend is a component now](#legend-is-a-component-now)
Want the 1.5 grays back|Load `tabler-themes.css` and set `data-bs-theme-base="gray"`, see [The default gray is neutral](#the-default-gray-is-neutral)
Use `data-sortable`|Dragging looks different, see [Sortable drags a styled copy](#sortable-drags-a-styled-copy)
Hide `path:first-child` in `.accordion-button-toggle-plus` from your own CSS|Drop that rule, see [Visual changes](#visual-changes) Hide `path:first-child` in `.accordion-button-toggle-plus` from your own CSS|Drop that rule, see [Visual changes](#visual-changes)
Call `tabler.tabler.getColor()` or `tabler.tabler.hexToRgba()`|Read the variable yourself, see [Removed JavaScript helpers](#removed-javascript-helpers) Call `tabler.tabler.getColor()` or `tabler.tabler.hexToRgba()`|Read the variable yourself, see [Removed JavaScript helpers](#removed-javascript-helpers)
Use `.form-hint`|Rename to `.form-text` (the old name still works) Use `.form-hint`|Rename to `.form-text` (the old name still works)
@@ -100,7 +104,7 @@ Component|Attribute|Docs
`tabler.Autosize`|`data-bs-toggle="autosize"`|[Autosize](/ui/plugins/autosize) `tabler.Autosize`|`data-bs-toggle="autosize"`|[Autosize](/ui/plugins/autosize)
`tabler.CountUp`|`data-countup`|[Countup](/ui/plugins/countup) `tabler.CountUp`|`data-countup`|[Countup](/ui/plugins/countup)
`tabler.InputMask`|`data-mask`|[Input mask](/ui/plugins/input-mask) `tabler.InputMask`|`data-mask`|[Input mask](/ui/plugins/input-mask)
`tabler.Sortable`|`data-sortable`|[Table](/ui/components/table) `tabler.Sortable`|`data-sortable`|[Sortable](/ui/plugins/sortable)
`tabler.SwitchIcon`|`data-bs-toggle="switch-icon"`|[Switch icon](/ui/components/switch-icon) `tabler.SwitchIcon`|`data-bs-toggle="switch-icon"`|[Switch icon](/ui/components/switch-icon)
Markup with data attributes keeps working as it did. Tabler still finds every element on page load and creates the component for it. What changes is the JavaScript side. Markup with data attributes keeps working as it did. Tabler still finds every element on page load and creates the component for it. What changes is the JavaScript side.
@@ -140,7 +144,16 @@ Options you passed to countUp.js in `data-countup` keep their names: `startVal`,
### IMask and SortableJS stay external ### IMask and SortableJS stay external
`InputMask` and `Sortable` are thin wrappers, so you still install [IMask](/ui/plugins/input-mask) and [SortableJS](/ui/components/table) yourself and load them before `tabler.js`. Nothing changes there. What's new is that the component keeps a reference to the library instance, so you can reach it from `getInstance()` instead of keeping your own. `InputMask` and `Sortable` are thin wrappers, so you still install [IMask](/ui/plugins/input-mask) and [SortableJS](/ui/plugins/sortable) yourself and load them before `tabler.js`. Nothing changes there. What's new is that the component keeps a reference to the library instance, so you can reach it from `getInstance()` instead of keeping your own.
#### Sortable drags a styled copy
The `Sortable` component now turns on the SortableJS `forceFallback` option. A dragged item is an HTML copy that Tabler styles with `.sortable-drag`, instead of the browser's own drag image. Turn the option off on a list to get the 1.5 behavior back:
```diff
- <ul class="list-group" data-sortable>
+ <ul class="list-group" data-sortable='{"forceFallback":false}'>
```
### Clipboard is built in ### Clipboard is built in
@@ -178,6 +191,35 @@ If you created the library yourself, switch to the component and its events:
The browser API works in secure contexts only, so on plain `http` the copy fails and `error.bs.clipboard` fires. `localhost` counts as secure. The browser API works in secure contexts only, so on plain `http` the copy fails and `error.bs.clipboard` fires. `localhost` counts as secure.
### Litepicker is replaced by Datepicker
[Litepicker](https://github.com/wakirin/Litepicker) has been archived since 2023, so Tabler dropped it. The package is gone from the dependencies and from `dist/libs`, and its styles are gone from `tabler-vendors.css`. A page that loads `dist/libs/litepicker/dist/litepicker.js` gets a 404 after the upgrade.
The new [Datepicker](/ui/plugins/date-picker) plugin is part of `tabler.js`. It renders its calendar with [Vanilla Calendar Pro](https://vanilla-calendar.pro), which you load in place of Litepicker:
```diff
- <script src="/dist/libs/litepicker/dist/litepicker.js"></script>
+ <script src="/dist/libs/vanilla-calendar-pro/index.js"></script>
<script src="/dist/js/tabler.min.js"></script>
```
```sh
npm uninstall litepicker
npm install vanilla-calendar-pro
```
Then replace the script that created the picker with a data attribute:
```diff
- <input class="form-control" id="date" />
- <script>
- new Litepicker({ element: document.getElementById('date') });
- </script>
+ <input type="text" class="form-control" id="date" data-bs-toggle="datepicker" autocomplete="off" />
```
The Datepicker page has a [table that maps every Litepicker option](/ui/plugins/date-picker#migration-from-litepicker) to its new name. Tabler only removed its own copy and styles, so a project that installs Litepicker on its own keeps working, but without Tabler's look.
### New events ### New events
Every component fires events on its element. If you used to poll or wrap the library callbacks, these are the ones to listen for: Every component fires events on its element. If you used to poll or wrap the library callbacks, these are the ones to listen for:
@@ -208,6 +250,17 @@ const primaryHalf = `color-mix(in oklab, ${primary} 50%, transparent)`;
Every place that accepts a CSS color, including canvas and most chart libraries that render to SVG, takes the `oklch()` string as it is. A library that parses colors itself and only understands hex or `rgb()` needs the value converted first. For ApexCharts you don't have to do anything, its theme reads the `--tblr-chart-*` variables since 1.5. Every place that accepts a CSS color, including canvas and most chart libraries that render to SVG, takes the `oklch()` string as it is. A library that parses colors itself and only understands hex or `rgb()` needs the value converted first. For ApexCharts you don't have to do anything, its theme reads the `--tblr-chart-*` variables since 1.5.
## Legend is a component now
In 1.5 `.legend` was a small colored dot. In 1.6 `.legend` is a whole legend item with a dot, a label and an optional value, and the dot is `.legend-dot`:
```diff
- <span class="legend bg-primary"></span>
+ <span class="legend-dot bg-primary"></span>
```
An empty `.legend` still renders as a dot, so nothing breaks today. It is deprecated, so rename it when you touch that markup. See [Legend](/ui/components/legend) for the new item, the list and the interactive states.
## Form hint is form text ## Form hint is form text
Tabler had its own `.form-hint` class for the helper text under a field, while Bootstrap calls the same thing `.form-text`. 1.6 uses the Bootstrap class and keeps `.form-hint` as a deprecated alias, so nothing breaks. Rename when you touch the markup anyway: Tabler had its own `.form-hint` class for the helper text under a field, while Bootstrap calls the same thing `.form-text`. 1.6 uses the Bootstrap class and keeps `.form-hint` as a deprecated alias, so nothing breaks. Rename when you touch the markup anyway:
@@ -321,6 +374,22 @@ A lot of Bootstrap variables that were plain Sass colors are now `color-mix()` e
You can still override them with anything Sass accepts. Just be aware that a Sass color function such as `darken()` or `rgba()` won't work on their default values any more, because the default isn't a color, it's a string with `var()` inside. You can still override them with anything Sass accepts. Just be aware that a Sass color function such as `darken()` or `rgba()` won't work on their default values any more, because the default isn't a color, it's a string with `var()` inside.
## The default gray is neutral
Every gray in Tabler comes from the `--tblr-gray-50` … `--tblr-gray-950` scale. In 1.5 that scale had a blue tint. In 1.6 the default is `neutral`, a pure gray, so backgrounds, borders and muted text look slightly different. The Sass defaults `$gray-50` … `$gray-950` changed the same way.
The old scale ships in `tabler-themes.css` as `gray`. To keep the 1.5 look, load that stylesheet and set the base on `<html>`:
```diff
- <html>
+ <html data-bs-theme-base="gray">
<head>
<link rel="stylesheet" href="/dist/css/tabler.min.css" />
+ <link rel="stylesheet" href="/dist/css/tabler-themes.min.css" />
```
`tabler-theme.js` also treats `neutral` as the default now. A visitor who picked `gray` in the theme settings keeps it. See [Theme base colors](/ui/plugins/theme-base-colors) for the other palettes.
## Visual changes ## Visual changes
None of these break markup, but they change how a page looks or scrolls. Worth a quick look at your screens after the upgrade. None of these break markup, but they change how a page looks or scrolls. Worth a quick look at your screens after the upgrade.
@@ -338,7 +407,9 @@ None of these break markup, but they change how a page looks or scrolls. Worth a
Besides the changes above, 1.6 adds: Besides the changes above, 1.6 adds:
- [Datepicker](/ui/plugins/date-picker), a calendar popup or inline calendar with single, multiple and range selection, see above.
- [Legend](/ui/components/legend), a colored marker plus label for chart series, with an interactive mode that toggles series on click. - [Legend](/ui/components/legend), a colored marker plus label for chart series, with an interactive mode that toggles series on click.
- [Signal](/ui/components/signal), a row of bars that shows a level, such as priority or signal strength.
- [Sparkline](/ui/components/sparkline), inline SVG line, bar and circle charts rendered from `data-bs-values`, with no chart library. - [Sparkline](/ui/components/sparkline), inline SVG line, bar and circle charts rendered from `data-bs-values`, with no chart library.
- [Password strength](/ui/forms/strength), a segmented meter that scores the password as the user types and fires `change.bs.strength`. - [Password strength](/ui/forms/strength), a segmented meter that scores the password as the user types and fires `change.bs.strength`.
- [OTP input](/ui/forms/otp-input), grouped single-character slots for one-time codes, with masking and validation states. - [OTP input](/ui/forms/otp-input), grouped single-character slots for one-time codes, with masking and validation states.
@@ -347,6 +418,8 @@ Besides the changes above, 1.6 adds:
- A `switch-icon-loading` state for [Switch icon](/ui/components/switch-icon). Pass a promise to `event.wait()` in `toggle.bs.switch-icon` and the button shows a spinner until it settles. - A `switch-icon-loading` state for [Switch icon](/ui/components/switch-icon). Pass a promise to `event.wait()` in `toggle.bs.switch-icon` and the button shows a spinner until it settles.
- `Autosize`, `CountUp`, `InputMask`, `Sortable` and `SwitchIcon` as real components with instances, methods and events, see above. - `Autosize`, `CountUp`, `InputMask`, `Sortable` and `SwitchIcon` as real components with instances, methods and events, see above.
- `data-tblr-countup` as an alias of `data-countup`. - `data-tblr-countup` as an alias of `data-countup`.
- `.navbar-floating` for [navbars](/ui/layout/navbars) and `.offcanvas-floating` for [offcanvas](/ui/components/offcanvas), which detach the element from the edge of the screen.
- Drag styles for [Sortable](/ui/plugins/sortable): `.sortable-ghost`, `.sortable-chosen` and `.sortable-drag`.
- A billing page in the preview, with plan usage, payment method and invoices. - A billing page in the preview, with plan usage, payment method and invoices.
- The Bootstrap component port is compiled with strict TypeScript, so the type declarations in the package are tighter. Configs are typed per component. - The Bootstrap component port is compiled with strict TypeScript, so the type declarations in the package are tighter. Configs are typed per component.
+14
View File
@@ -31,6 +31,20 @@ The plugin itself ships in `tabler.js`. The calendar it renders comes from Vanil
<TabsPackage name="vanilla-calendar-pro" /> <TabsPackage name="vanilla-calendar-pro" />
If you use a bundler, put the library on `window` before Tabler loads. The plugin reads `window.VanillaCalendarPro` and does nothing without it. Imports run before the rest of a file, so do it in a file of its own and import that one first:
```javascript
// vanilla-calendar-global.js
import * as VanillaCalendarPro from 'vanilla-calendar-pro'
window.VanillaCalendarPro = VanillaCalendarPro
```
```javascript
import './vanilla-calendar-global.js'
import '@tabler/core'
```
Or include it from a CDN, before `tabler.js`: Or include it from a CDN, before `tabler.js`:
<Code lang="html" code={`<script src="${site.cdnUrl}/dist/libs/vanilla-calendar-pro/index.js"></script>`} /> <Code lang="html" code={`<script src="${site.cdnUrl}/dist/libs/vanilla-calendar-pro/index.js"></script>`} />
+141
View File
@@ -0,0 +1,141 @@
---
title: Sortable
summary: Sortable lets users reorder the items of a list, a table or a grid by dragging them. It wraps SortableJS in a Tabler component and styles the dragged item.
description: Make lists, table rows and cards draggable with the data-sortable attribute, SortableJS options as JSON and the Sortable component.
docs-libs: [sortablejs]
added-in: '1.6.0'
related: [/ui/components/list-group, /ui/components/table]
---
import Example from '@components/Example.astro'
import TabsPackage from '@components/TabsPackage.astro'
import CodeDocs from '@components/CodeDocs.astro';
import { Code } from 'astro:components'
import { site } from '@shared/lib/site.ts'
## Overview
Use Sortable where the order is up to the user, such as a task list, table rows or dashboard cards. The component is part of `tabler.js`. The dragging itself comes from [SortableJS](https://sortablejs.github.io/Sortable/), which you load separately.
## Installation
Install the SortableJS library:
<TabsPackage name="sortablejs" />
If you use a bundler, put the library on `window` before Tabler loads. The component reads `window.Sortable` and does nothing without it. Imports run before the rest of a file, so do it in a file of its own and import that one first:
```javascript
// sortable-global.js
import Sortable from 'sortablejs'
window.Sortable = Sortable
```
```javascript
import './sortable-global.js'
import '@tabler/core'
```
Or include it from a CDN, before `tabler.js`:
<Code lang="html" code={`<script src="${site.cdnUrl}/dist/libs/sortablejs/Sortable.min.js"></script>`} />
## Usage
### Basic list
Add `data-sortable` to the parent element. Its direct children become draggable.
<Example column>
<ul class="list-group" data-sortable>
<li class="list-group-item">Design the landing page</li>
<li class="list-group-item">Write the release notes</li>
<li class="list-group-item">Review open pull requests</li>
</ul>
</Example>
### Options
Pass [SortableJS options](https://github.com/SortableJS/Sortable#options) as JSON in the attribute. This list animates the items as they move.
<Example column>
<ul class="list-group" data-sortable='{"animation":150}'>
<li class="list-group-item">First</li>
<li class="list-group-item">Second</li>
<li class="list-group-item">Third</li>
</ul>
</Example>
### Drag handle
Set `handle` to a selector to start the drag only from that element. The rest of the item stays clickable.
```html
<ul class="list-group" data-sortable='{"animation":150,"handle":".sortable-handle"}'>
<li class="list-group-item d-flex gap-2">
<span class="sortable-handle cursor-move">⋮⋮</span>
Design the landing page
</li>
</ul>
```
### Table rows
Put `data-sortable` on the `tbody` to reorder the rows of a table.
```html
<table class="table">
<tbody data-sortable='{"animation":150}'>
<tr><td>First row</td></tr>
<tr><td>Second row</td></tr>
</tbody>
</table>
```
### Drag styles
Tabler styles the classes SortableJS adds while dragging: `.sortable-ghost` marks the drop position, `.sortable-chosen` the picked item and `.sortable-drag` the copy that follows the pointer.
The component turns on the `forceFallback` option by default. SortableJS then drags an HTML copy of the item instead of the browser's drag image, so `.sortable-drag` can style it. To get the browser's drag image back, turn the option off:
```html
<ul class="list-group" data-sortable='{"forceFallback":false}'>
…
</ul>
```
## JavaScript
Tabler wraps the plugin in a `Sortable` component that works like the Bootstrap components: it is created once per element and stored on it. On page load Tabler creates one for every element with `data-sortable`. This is the code that runs:
<CodeDocs name="sortable-init" file="core/js/src/sortable.ts" />
Create the component yourself for a list added later. Options come from `data-sortable`, from the config object, or both. The object wins.
```js
const list = new tabler.Sortable(document.getElementById('tasks'), { animation: 150 });
```
Method|Description
---|---
`toArray()`|Returns the `data-id` of every item, in the current order.
`sort(order, useAnimation)`|Reorders the items to match an array of ids.
`dispose()`|Destroys the SortableJS instance and removes the component.
The `sortable` getter returns the SortableJS instance, so everything from the [SortableJS API](https://github.com/SortableJS/Sortable#methods) is available:
```js
const list = tabler.Sortable.getOrCreateInstance(document.getElementById('tasks'));
list.sortable.option('disabled', true);
```
## Accessibility
- Dragging needs a pointer. SortableJS has no keyboard support, so give keyboard users another way to change the order, such as "Move up" and "Move down" buttons.
- Use a drag handle when the items contain links or buttons, so a click on them does not start a drag.
- Save the new order only after the drop, and tell the user when saving fails.
## SCSS variables
<CodeDocs name="sortable-variables" file="core/scss/_variables.scss" />
+1 -1
View File
@@ -28,7 +28,7 @@ Or include it from a CDN. You need three files: the stylesheet, the library, and
<Code <Code
lang="html" lang="html"
code={`<link rel="stylesheet" href="${site.cdnUrl}/dist/libs/jsvectormap/dist/jsvectormap.min.css" /> code={`<link rel="stylesheet" href="${site.cdnUrl}/dist/libs/jsvectormap/dist/jsvectormap.css" />
<script src="${site.cdnUrl}/dist/libs/jsvectormap/dist/jsvectormap.min.js"></script> <script src="${site.cdnUrl}/dist/libs/jsvectormap/dist/jsvectormap.min.js"></script>
<script src="${site.cdnUrl}/dist/libs/jsvectormap/dist/maps/world.js"></script>`} <script src="${site.cdnUrl}/dist/libs/jsvectormap/dist/maps/world.js"></script>`}
/> />
+2
View File
@@ -1,5 +1,6 @@
--- ---
import DefaultLayout from '@shared/layouts/DefaultLayout.astro' import DefaultLayout from '@shared/layouts/DefaultLayout.astro'
import DocsLink from '@ui/DocsLink.astro'
import Avatar from '@ui/Avatar.astro' import Avatar from '@ui/Avatar.astro'
import Card from '@ui/Card.astro' import Card from '@ui/Card.astro'
import CardHeader from '@ui/CardHeader.astro' import CardHeader from '@ui/CardHeader.astro'
@@ -50,6 +51,7 @@ const sections = [
--- ---
<DefaultLayout title="Sortable" pageMenu="plugins.sortable" pageLibs={['sortablejs']} description="Drag and drop to reorder lists, table rows, grids and kanban boards with SortableJS."> <DefaultLayout title="Sortable" pageMenu="plugins.sortable" pageLibs={['sortablejs']} description="Drag and drop to reorder lists, table rows, grids and kanban boards with SortableJS.">
<DocsLink slot="page-header-actions" path="/ui/plugins/sortable" />
<div class="row row-cards"> <div class="row row-cards">
<div class="col-12"> <div class="col-12">
<h2 class="page-section-title">{sections[0]!.title}</h2> <h2 class="page-section-title">{sections[0]!.title}</h2>
+4
View File
@@ -500,6 +500,10 @@
"title": "Social icons", "title": "Social icons",
"url": "/ui/plugins/social-icons" "url": "/ui/plugins/social-icons"
}, },
{
"title": "Sortable",
"url": "/ui/plugins/sortable"
},
{ {
"title": "Theme base colors", "title": "Theme base colors",
"url": "/ui/plugins/theme-base-colors" "url": "/ui/plugins/theme-base-colors"