From 7bfeb12ab762280a3d3cc177c422603944a3cc41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Kuna?= <1282324+codecalm@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:20:16 +0200 Subject: [PATCH] Fix the bugs found in the pre-1.6 review of `dev` (#3112) --- .agents/rules/main.mdc | 1 + .agents/skills/backward-compat/SKILL.md | 104 +++++++++++++ .agents/skills/core-js/SKILL.md | 1 + .agents/skills/core-scss/SKILL.md | 1 + .agents/skills/generate-changeset/SKILL.md | 2 +- .browserslistrc | 3 +- .changeset/autosize-component.md | 2 +- .../docs-browser-support-relative-colors.md | 5 + .changeset/docs-sortable-page.md | 6 + .changeset/docs-upgrade-1-5-gray-text.md | 5 + .../docs-upgrade-1-6-missing-changes.md | 5 + .changeset/fix-alert-link-hover-color.md | 5 + .changeset/fix-card-options-position.md | 5 + .changeset/fix-datepicker-toggle-events.md | 5 + .changeset/fix-focus-ring-overrides.md | 5 + .changeset/fix-form-switch-rtl-lg-knob.md | 5 + .changeset/fix-init-all-invalid-config.md | 5 + .changeset/fix-published-types.md | 5 + .changeset/legend-component.md | 2 +- .changeset/switch-icon-loading.md | 2 +- .changeset/tabler-components.md | 2 +- core/js/src/autosize.ts | 6 +- .../src/bootstrap/util/component-functions.ts | 21 ++- core/js/src/clipboard.ts | 5 +- core/js/src/countup.ts | 6 +- core/js/src/datepicker.ts | 121 ++++++++++++--- core/js/src/global.d.ts | 26 +--- core/js/src/input-mask.ts | 16 +- core/js/src/otp-input.ts | 5 +- core/js/src/sortable.ts | 15 +- core/js/src/sparkline.ts | 6 +- core/js/src/strength.ts | 5 +- core/js/src/switch-icon.ts | 5 +- core/js/tests/unit/datepicker.spec.ts | 63 ++++++++ .../unit/util/component-functions.spec.ts | 45 +++++- core/package.json | 8 + core/scss/_extends.scss | 7 + core/scss/bootstrap/_root.scss | 1 - core/scss/bootstrap/forms/_form-check.scss | 3 +- core/scss/mixins/_functions.scss | 5 + core/scss/mixins/_mixins.scss | 5 +- .../css-var-prefix.test.mjs.snap | 2 + core/scss/tests/_mixins.test.scss | 4 +- core/scss/ui/_alerts.scss | 4 + core/scss/ui/_cards.scss | 3 - core/scss/ui/_datepicker.scss | 9 -- core/scss/ui/forms/_form-check.scss | 3 +- .../ui/getting-started/browser-support.mdx | 3 + .../ui/getting-started/upgrade/1-5.mdx | 14 ++ .../ui/getting-started/upgrade/1-6.mdx | 79 +++++++++- docs/content/ui/plugins/date-picker.mdx | 14 ++ docs/content/ui/plugins/sortable.mdx | 141 ++++++++++++++++++ docs/content/ui/plugins/vector-map.mdx | 2 +- preview/pages/sortable.astro | 2 + shared/data/docs.json | 4 + 55 files changed, 728 insertions(+), 106 deletions(-) create mode 100644 .agents/skills/backward-compat/SKILL.md create mode 100644 .changeset/docs-browser-support-relative-colors.md create mode 100644 .changeset/docs-sortable-page.md create mode 100644 .changeset/docs-upgrade-1-5-gray-text.md create mode 100644 .changeset/docs-upgrade-1-6-missing-changes.md create mode 100644 .changeset/fix-alert-link-hover-color.md create mode 100644 .changeset/fix-card-options-position.md create mode 100644 .changeset/fix-datepicker-toggle-events.md create mode 100644 .changeset/fix-focus-ring-overrides.md create mode 100644 .changeset/fix-form-switch-rtl-lg-knob.md create mode 100644 .changeset/fix-init-all-invalid-config.md create mode 100644 .changeset/fix-published-types.md create mode 100644 docs/content/ui/plugins/sortable.mdx diff --git a/.agents/rules/main.mdc b/.agents/rules/main.mdc index a029865f8..685007c4c 100644 --- a/.agents/rules/main.mdc +++ b/.agents/rules/main.mdc @@ -30,6 +30,7 @@ Task-specific instructions live in `.agents/skills//SKILL.md` — read the | `shared-lib` | moving logic into `shared/lib/` and testing it | | `core-scss` | writing or changing styles in `core/scss/` | | `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) | | `demo-pages` | adding or updating a page in `preview/pages/` | | `write-docs` | writing or updating a page in `docs/content/` | diff --git a/.agents/skills/backward-compat/SKILL.md b/.agents/skills/backward-compat/SKILL.md new file mode 100644 index 000000000..a212c12c3 --- /dev/null +++ b/.agents/skills/backward-compat/SKILL.md @@ -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 `` `` is deprecated, use `` instead ``. +- **Custom property**: keep reading the old name as a fallback — `var(--new, var(--old, ))` — 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. diff --git a/.agents/skills/core-js/SKILL.md b/.agents/skills/core-js/SKILL.md index 7e79b7f8b..3b25525bf 100644 --- a/.agents/skills/core-js/SKILL.md +++ b/.agents/skills/core-js/SKILL.md @@ -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) - [ ] `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 - [ ] `js-docs-*` markers intact and self-contained - [ ] `pnpm --filter @tabler/core test` (js + scss) clean diff --git a/.agents/skills/core-scss/SKILL.md b/.agents/skills/core-scss/SKILL.md index 521aa5682..e2f8b5eb2 100644 --- a/.agents/skills/core-scss/SKILL.md +++ b/.agents/skills/core-scss/SKILL.md @@ -118,6 +118,7 @@ pnpm run bundlewatch # size budgets (tabler.css 80 kB, tab - [ ] Custom properties written bare; new foreign names added to `cssVarIgnore` - [ ] Dark mode via `light-dark()` or all three dark selectors, behind `$enable-dark-mode` - [ ] 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 - [ ] `lint:scss`, `check:tokens`, `lint:prettier`, `test:scss` clean - [ ] Docs page and class table updated (`write-docs`, `class-reference`), changeset written diff --git a/.agents/skills/generate-changeset/SKILL.md b/.agents/skills/generate-changeset/SKILL.md index d5420964b..b796a8ef8 100644 --- a/.agents/skills/generate-changeset/SKILL.md +++ b/.agents/skills/generate-changeset/SKILL.md @@ -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 | | **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. diff --git a/.browserslistrc b/.browserslistrc index f7d5bd6e6..ee2bfa497 100644 --- a/.browserslistrc +++ b/.browserslistrc @@ -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: # light-dark() Chrome 123, Firefox 120, Safari 17.5 # @property Chrome 85, Firefox 128, Safari 16.4 # :has() Chrome 105, Firefox 121, Safari 15.4 # 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. Chrome >= 123 Edge >= 123 diff --git a/.changeset/autosize-component.md b/.changeset/autosize-component.md index 8782cbe05..afa50331f 100644 --- a/.changeset/autosize-component.md +++ b/.changeset/autosize-component.md @@ -4,4 +4,4 @@ "@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. diff --git a/.changeset/docs-browser-support-relative-colors.md b/.changeset/docs-browser-support-relative-colors.md new file mode 100644 index 000000000..13ddcdcf1 --- /dev/null +++ b/.changeset/docs-browser-support-relative-colors.md @@ -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. diff --git a/.changeset/docs-sortable-page.md b/.changeset/docs-sortable-page.md new file mode 100644 index 000000000..a47e1d6f8 --- /dev/null +++ b/.changeset/docs-sortable-page.md @@ -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. diff --git a/.changeset/docs-upgrade-1-5-gray-text.md b/.changeset/docs-upgrade-1-5-gray-text.md new file mode 100644 index 000000000..52e1bc2aa --- /dev/null +++ b/.changeset/docs-upgrade-1-5-gray-text.md @@ -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. diff --git a/.changeset/docs-upgrade-1-6-missing-changes.md b/.changeset/docs-upgrade-1-6-missing-changes.md new file mode 100644 index 000000000..eb6eab251 --- /dev/null +++ b/.changeset/docs-upgrade-1-6-missing-changes.md @@ -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. diff --git a/.changeset/fix-alert-link-hover-color.md b/.changeset/fix-alert-link-hover-color.md new file mode 100644 index 000000000..8adcdeb45 --- /dev/null +++ b/.changeset/fix-alert-link-hover-color.md @@ -0,0 +1,5 @@ +--- +"@tabler/core": patch +--- + +Fixed `.alert-link` changing color on hover. diff --git a/.changeset/fix-card-options-position.md b/.changeset/fix-card-options-position.md new file mode 100644 index 000000000..25d9ebf6a --- /dev/null +++ b/.changeset/fix-card-options-position.md @@ -0,0 +1,5 @@ +--- +"@tabler/core": patch +--- + +Fixed `.card-options` being taken out of the `.card-header` flow by `position: absolute`. diff --git a/.changeset/fix-datepicker-toggle-events.md b/.changeset/fix-datepicker-toggle-events.md new file mode 100644 index 000000000..d6c530555 --- /dev/null +++ b/.changeset/fix-datepicker-toggle-events.md @@ -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. diff --git a/.changeset/fix-focus-ring-overrides.md b/.changeset/fix-focus-ring-overrides.md new file mode 100644 index 000000000..9777d0ab9 --- /dev/null +++ b/.changeset/fix-focus-ring-overrides.md @@ -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. diff --git a/.changeset/fix-form-switch-rtl-lg-knob.md b/.changeset/fix-form-switch-rtl-lg-knob.md new file mode 100644 index 000000000..29f3105ff --- /dev/null +++ b/.changeset/fix-form-switch-rtl-lg-knob.md @@ -0,0 +1,5 @@ +--- +"@tabler/core": patch +--- + +Fixed the `.form-switch` knob not moving in RTL and its size in `.form-switch-lg`. diff --git a/.changeset/fix-init-all-invalid-config.md b/.changeset/fix-init-all-invalid-config.md new file mode 100644 index 000000000..0fd81ec14 --- /dev/null +++ b/.changeset/fix-init-all-invalid-config.md @@ -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. diff --git a/.changeset/fix-published-types.md b/.changeset/fix-published-types.md new file mode 100644 index 000000000..757b4872d --- /dev/null +++ b/.changeset/fix-published-types.md @@ -0,0 +1,5 @@ +--- +"@tabler/core": patch +--- + +Fixed missing `IMaskInstance` and `SortableInstance` in `dist/types`; `vanilla-calendar-pro` is an optional peer dependency. diff --git a/.changeset/legend-component.md b/.changeset/legend-component.md index 4f26803ab..21f45c419 100644 --- a/.changeset/legend-component.md +++ b/.changeset/legend-component.md @@ -4,4 +4,4 @@ "@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`. diff --git a/.changeset/switch-icon-loading.md b/.changeset/switch-icon-loading.md index bc6e9f6b5..52c926987 100644 --- a/.changeset/switch-icon-loading.md +++ b/.changeset/switch-icon-loading.md @@ -4,4 +4,4 @@ "@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. diff --git a/.changeset/tabler-components.md b/.changeset/tabler-components.md index ac7e4897c..1a51ee1f5 100644 --- a/.changeset/tabler-components.md +++ b/.changeset/tabler-components.md @@ -3,4 +3,4 @@ "@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`. diff --git a/core/js/src/autosize.ts b/core/js/src/autosize.ts index cf052b8bb..e71a908eb 100644 --- a/core/js/src/autosize.ts +++ b/core/js/src/autosize.ts @@ -7,7 +7,7 @@ import BaseComponent from './bootstrap/base-component' 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' type ComponentConfig = Record @@ -134,9 +134,7 @@ class Autosize extends BaseComponent { */ // js-docs-start autosize-init -for (const element of SelectorEngine.find(SELECTOR_DATA_TOGGLE)) { - Autosize.getOrCreateInstance(element) -} +initAll(SELECTOR_DATA_TOGGLE, Autosize) // js-docs-end autosize-init export default Autosize diff --git a/core/js/src/bootstrap/util/component-functions.ts b/core/js/src/bootstrap/util/component-functions.ts index 48dee659c..cc3e4f853 100644 --- a/core/js/src/bootstrap/util/component-functions.ts +++ b/core/js/src/bootstrap/util/component-functions.ts @@ -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 } diff --git a/core/js/src/clipboard.ts b/core/js/src/clipboard.ts index 993fbf7c1..d1ae1cf76 100644 --- a/core/js/src/clipboard.ts +++ b/core/js/src/clipboard.ts @@ -8,6 +8,7 @@ import BaseComponent from './bootstrap/base-component' 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' type ComponentConfig = { @@ -190,9 +191,7 @@ class Clipboard extends BaseComponent { */ // js-docs-start clipboard-init -for (const element of SelectorEngine.find(SELECTOR_DATA_TOGGLE)) { - Clipboard.getOrCreateInstance(element) -} +initAll(SELECTOR_DATA_TOGGLE, Clipboard) // js-docs-end clipboard-init export default Clipboard diff --git a/core/js/src/countup.ts b/core/js/src/countup.ts index 6f145cbcd..f21e62654 100644 --- a/core/js/src/countup.ts +++ b/core/js/src/countup.ts @@ -7,7 +7,7 @@ import BaseComponent from './bootstrap/base-component' 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' type CountUpFormat = 'number' | 'time' @@ -312,9 +312,7 @@ class CountUp extends BaseComponent { */ // js-docs-start countup-init -for (const element of SelectorEngine.find(SELECTOR_DATA_COUNTUP)) { - CountUp.getOrCreateInstance(element) -} +initAll(SELECTOR_DATA_COUNTUP, CountUp) // js-docs-end countup-init export default CountUp diff --git a/core/js/src/datepicker.ts b/core/js/src/datepicker.ts index b3a860da2..70ebe863f 100644 --- a/core/js/src/datepicker.ts +++ b/core/js/src/datepicker.ts @@ -9,6 +9,7 @@ import type { Calendar, DateAny, DateMode, DatesArr, MonthsCount, Options, Posit import BaseComponent from './bootstrap/base-component' import EventHandler from './bootstrap/dom/event-handler' import SelectorEngine from './bootstrap/dom/selector-engine' +import { initAll } from './bootstrap/util/component-functions' import { isDisabled } from './bootstrap/util/index' import type { ElementSelector } from './bootstrap/types' @@ -106,6 +107,13 @@ const DefaultType: Record = { 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 * @@ -120,6 +128,14 @@ class Datepicker extends BaseComponent { declare _config: ComponentConfig _calendar: Calendar | null = null _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 _isInline = false _boundInput: HTMLInputElement | null = null @@ -173,7 +189,9 @@ class Datepicker extends BaseComponent { 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 } @@ -182,10 +200,20 @@ class Datepicker extends BaseComponent { return } + this._isShowing = true + this._skipPluginShow = false 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((resolve) => { + this._resolveShown = resolve + afterPluginTimers(resolve) + }) + } + + this._resolveShown = null + this._isShowing = false } async hide(): Promise { @@ -202,10 +230,16 @@ class Datepicker extends BaseComponent { return } - this._calendar.hide() - this._isShown = false + // On a click on a button trigger the plugin's own listener runs next and + // 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 { @@ -419,6 +453,58 @@ class Datepicker extends BaseComponent { 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 { // The plugin uses 'system' for auto-detection, Bootstrap and Tabler use 'auto' const theme = this._getEffectiveTheme() @@ -440,18 +526,8 @@ class Datepicker extends BaseComponent { onInit: (self) => { this._syncThemeAttribute(self.context.mainElement) }, - onShow: () => { - if (!this._calendar) { - return - } - - this._isShown = true - this._syncThemeAttribute(this._calendar.context.mainElement) - this._alignToPositionElement() - }, - onHide: () => { - this._isShown = false - }, + onShow: () => this._handlePluginShow(), + onHide: () => this._handlePluginHide(), } // 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, // 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 eager = dataset.bsInline === 'true' || dataset.tblrInline === 'true' || 'bsSelectedDates' in dataset || 'tblrSelectedDates' in dataset || (element as HTMLInputElement).value - if (eager) { - Datepicker.getOrCreateInstance(element) - } -} + return dataset.bsInline === 'true' || dataset.tblrInline === 'true' || 'bsSelectedDates' in dataset || 'tblrSelectedDates' in dataset || Boolean((element as HTMLInputElement).value) +}) // js-docs-end datepicker-init export default Datepicker diff --git a/core/js/src/global.d.ts b/core/js/src/global.d.ts index 1377da500..92375b8f6 100644 --- a/core/js/src/global.d.ts +++ b/core/js/src/global.d.ts @@ -1,23 +1,9 @@ -// IMask (https://imask.js.org) is loaded separately, not bundled -interface IMaskInstance { - value: string - unmaskedValue: string - updateValue(): void - updateOptions(options: Record): 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 -} - +// Plugins that are loaded separately, not bundled: IMask (https://imask.js.org), +// SortableJS (https://sortablejs.github.io/Sortable/) and Vanilla Calendar Pro +// (https://vanilla-calendar.pro). The instance types live in the component +// modules, so they are part of the published `dist/types`. interface Window { - // Vanilla Calendar Pro (https://vanilla-calendar.pro) is loaded separately, not bundled VanillaCalendarPro?: typeof import('vanilla-calendar-pro') - IMask?: new (element: HTMLElement, options: { mask: string; lazy?: boolean }) => IMaskInstance - Sortable?: new (element: HTMLElement, options?: Record) => SortableInstance + IMask?: new (element: HTMLElement, options: { mask: string; lazy?: boolean }) => import('./input-mask').IMaskInstance + Sortable?: new (element: HTMLElement, options?: Record) => import('./sortable').SortableInstance } diff --git a/core/js/src/input-mask.ts b/core/js/src/input-mask.ts index 4713d456c..3fc777efb 100644 --- a/core/js/src/input-mask.ts +++ b/core/js/src/input-mask.ts @@ -6,9 +6,18 @@ */ 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' +// The part of an IMask instance the component uses +interface IMaskInstance { + value: string + unmaskedValue: string + updateValue(): void + updateOptions(options: Record): void + destroy(): void +} + type ComponentConfig = { mask: string lazy: boolean @@ -114,9 +123,8 @@ class InputMask extends BaseComponent { */ // js-docs-start input-mask-init -for (const element of SelectorEngine.find(SELECTOR_DATA_MASK)) { - InputMask.getOrCreateInstance(element) -} +initAll(SELECTOR_DATA_MASK, InputMask) // js-docs-end input-mask-init export default InputMask +export type { IMaskInstance } diff --git a/core/js/src/otp-input.ts b/core/js/src/otp-input.ts index 2e41a8a00..41e257c16 100644 --- a/core/js/src/otp-input.ts +++ b/core/js/src/otp-input.ts @@ -8,6 +8,7 @@ import BaseComponent from './bootstrap/base-component' 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' type OtpInputType = 'numeric' | 'alphanumeric' | 'alpha' @@ -425,9 +426,7 @@ class OtpInput extends BaseComponent { */ // js-docs-start otp-init -for (const element of SelectorEngine.find(SELECTOR_DATA_TOGGLE)) { - OtpInput.getOrCreateInstance(element) -} +initAll(SELECTOR_DATA_TOGGLE, OtpInput) // js-docs-end otp-init export default OtpInput diff --git a/core/js/src/sortable.ts b/core/js/src/sortable.ts index e6f793c03..a37f43cec 100644 --- a/core/js/src/sortable.ts +++ b/core/js/src/sortable.ts @@ -6,9 +6,17 @@ */ 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' +// 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 type ComponentConfigInput = Record @@ -108,9 +116,8 @@ class Sortable extends BaseComponent { */ // js-docs-start sortable-init -for (const element of SelectorEngine.find(SELECTOR_DATA_SORTABLE)) { - Sortable.getOrCreateInstance(element) -} +initAll(SELECTOR_DATA_SORTABLE, Sortable) // js-docs-end sortable-init export default Sortable +export type { SortableInstance } diff --git a/core/js/src/sparkline.ts b/core/js/src/sparkline.ts index aac218ce5..43151bb86 100644 --- a/core/js/src/sparkline.ts +++ b/core/js/src/sparkline.ts @@ -7,7 +7,7 @@ import BaseComponent from './bootstrap/base-component' 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' type SparklineType = 'line' | 'bar' | 'circle' | 'tristate' @@ -603,9 +603,7 @@ class Sparkline extends BaseComponent { */ // js-docs-start sparkline-init -for (const element of SelectorEngine.find(SELECTOR_DATA_TOGGLE)) { - Sparkline.getOrCreateInstance(element) -} +initAll(SELECTOR_DATA_TOGGLE, Sparkline) // js-docs-end sparkline-init export default Sparkline diff --git a/core/js/src/strength.ts b/core/js/src/strength.ts index 1b3970aa4..3c35baa71 100644 --- a/core/js/src/strength.ts +++ b/core/js/src/strength.ts @@ -8,6 +8,7 @@ import BaseComponent from './bootstrap/base-component' 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' type StrengthLevel = 'weak' | 'fair' | 'good' | 'strong' @@ -270,9 +271,7 @@ class Strength extends BaseComponent { */ // js-docs-start strength-init -for (const element of SelectorEngine.find(SELECTOR_DATA_STRENGTH)) { - Strength.getOrCreateInstance(element) -} +initAll(SELECTOR_DATA_STRENGTH, Strength) // js-docs-end strength-init export default Strength diff --git a/core/js/src/switch-icon.ts b/core/js/src/switch-icon.ts index 2abbd411f..4960e65d1 100644 --- a/core/js/src/switch-icon.ts +++ b/core/js/src/switch-icon.ts @@ -8,6 +8,7 @@ import BaseComponent from './bootstrap/base-component' 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' type ComponentConfig = Record @@ -160,9 +161,7 @@ class SwitchIcon extends BaseComponent { */ // js-docs-start switch-icon-init -for (const element of SelectorEngine.find(SELECTOR_DATA_TOGGLE)) { - SwitchIcon.getOrCreateInstance(element) -} +initAll(SELECTOR_DATA_TOGGLE, SwitchIcon) // js-docs-end switch-icon-init export default SwitchIcon diff --git a/core/js/tests/unit/datepicker.spec.ts b/core/js/tests/unit/datepicker.spec.ts index 58668c061..2a4a0ad82 100644 --- a/core/js/tests/unit/datepicker.spec.ts +++ b/core/js/tests/unit/datepicker.spec.ts @@ -27,6 +27,7 @@ describe('Datepicker', () => { }) const input = (): HTMLInputElement => fixtureEl.querySelector('input')! + const tick = (): Promise => new Promise((resolve) => setTimeout(resolve, 20)) describe('NAME', () => { it('should return plugin name', () => { @@ -243,5 +244,67 @@ describe('Datepicker', () => { expect(instance).not.toBeNull() expect(instance!._isShown).toBe(true) }) + + it('should close on the second click of a button trigger', async () => { + fixtureEl.innerHTML = '' + + 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 = '' + 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 = '' + 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']) + }) }) }) diff --git a/core/js/tests/unit/util/component-functions.spec.ts b/core/js/tests/unit/util/component-functions.spec.ts index 6cb8e39e4..3b6dadcbf 100644 --- a/core/js/tests/unit/util/component-functions.spec.ts +++ b/core/js/tests/unit/util/component-functions.spec.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeAll, afterEach, vi } from 'vitest' 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' class DummyClass extends BaseComponent { @@ -145,4 +145,47 @@ describe('Component Functions', () => { vi.restoreAllMocks() }) }) + describe('initAll', () => { + it('should create an instance for every matching element', () => { + fixtureEl.innerHTML = '
' + + initAll('.init-all', DummyClass) + + for (const element of fixtureEl.querySelectorAll('.init-all')) { + expect(DummyClass.getInstance(element)).toBeInstanceOf(DummyClass) + } + }) + + it('should skip the elements the filter rejects', () => { + fixtureEl.innerHTML = '
' + + initAll('.init-all', DummyClass, (element) => element.id === 'keep') + + expect(DummyClass.getInstance(fixtureEl.querySelector('#skip'))).toBeNull() + expect(DummyClass.getInstance(fixtureEl.querySelector('#keep'))).toBeInstanceOf(DummyClass) + }) + + it('should log the error and go on when one element throws', () => { + fixtureEl.innerHTML = '
' + + 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('#fine'))).toBeInstanceOf(DummyClass) + + spyError.mockRestore() + }) + }) }) diff --git a/core/package.json b/core/package.json index 0cffa6819..1550cabaa 100644 --- a/core/package.json +++ b/core/package.json @@ -245,6 +245,14 @@ "dependencies": { "@popperjs/core": "^2.11.8" }, + "peerDependencies": { + "vanilla-calendar-pro": "^3.3.2" + }, + "peerDependenciesMeta": { + "vanilla-calendar-pro": { + "optional": true + } + }, "devDependencies": { "@melloware/coloris": "^0.25.0", "@types/node": "^26.4.0", diff --git a/core/scss/_extends.scss b/core/scss/_extends.scss index e76ec7db9..0374103e0 100644 --- a/core/scss/_extends.scss +++ b/core/scss/_extends.scss @@ -111,3 +111,10 @@ // Kept from the old rule, so an inline element still gets the top margin. 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; +} diff --git a/core/scss/bootstrap/_root.scss b/core/scss/bootstrap/_root.scss index a2946123e..99f2cb2ab 100644 --- a/core/scss/bootstrap/_root.scss +++ b/core/scss/bootstrap/_root.scss @@ -122,7 +122,6 @@ --focus-ring-opacity: #{$focus-ring-opacity}; --focus-ring-opacity-dark: #{$focus-ring-opacity-dark}; --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-start root-form-validation-variables diff --git a/core/scss/bootstrap/forms/_form-check.scss b/core/scss/bootstrap/forms/_form-check.scss index 3618aa437..a527fa9eb 100644 --- a/core/scss/bootstrap/forms/_form-check.scss +++ b/core/scss/bootstrap/forms/_form-check.scss @@ -145,7 +145,8 @@ // The knob replaces the masked check icon: a plain circle that slides. &::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); height: var(--form-switch-knob-size); margin-block: auto; diff --git a/core/scss/mixins/_functions.scss b/core/scss/mixins/_functions.scss index 112d139ad..84eb4a23b 100644 --- a/core/scss/mixins/_functions.scss +++ b/core/scss/mixins/_functions.scss @@ -29,6 +29,11 @@ // Runtime counterpart of `adjust-lightness()`: relative color syntax, so the // 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) { $delta: math.div($amount, 100%); $sign: if(sass($delta < 0): '-'; else: '+'); diff --git a/core/scss/mixins/_mixins.scss b/core/scss/mixins/_mixins.scss index 2297b66a4..9e160575b 100644 --- a/core/scss/mixins/_mixins.scss +++ b/core/scss/mixins/_mixins.scss @@ -66,11 +66,14 @@ 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) { @if $color != null { outline: var(--focus-ring-width) solid #{$color}; } @else { - outline: var(--focus-ring); + outline: var(--focus-ring, var(--focus-ring-width) solid var(--focus-ring-color)); } @if $offset { outline-offset: var(--focus-ring-offset); diff --git a/core/scss/tests/__snapshots__/css-var-prefix.test.mjs.snap b/core/scss/tests/__snapshots__/css-var-prefix.test.mjs.snap index 252823c3b..27e51e240 100644 --- a/core/scss/tests/__snapshots__/css-var-prefix.test.mjs.snap +++ b/core/scss/tests/__snapshots__/css-var-prefix.test.mjs.snap @@ -44,9 +44,11 @@ exports[`css custom-property prefixing > leaves vendor-owned names alone and pre "--tblr-chart-5", "--tblr-danger", "--tblr-focus-ring", + "--tblr-focus-ring-color", "--tblr-focus-ring-offset", "--tblr-focus-ring-opacity", "--tblr-focus-ring-opacity-dark", + "--tblr-focus-ring-width", "--tblr-font-sans-serif", "--tblr-font-size-h3", "--tblr-font-size-h5", diff --git a/core/scss/tests/_mixins.test.scss b/core/scss/tests/_mixins.test.scss index 8805ada05..8a93e349b 100644 --- a/core/scss/tests/_mixins.test.scss +++ b/core/scss/tests/_mixins.test.scss @@ -113,7 +113,7 @@ } @include expect() { .t { - outline: var(--focus-ring); + outline: var(--focus-ring, var(--focus-ring-width) solid var(--focus-ring-color)); } } } @@ -128,7 +128,7 @@ } @include expect() { .t { - outline: var(--focus-ring); + outline: var(--focus-ring, var(--focus-ring-width) solid var(--focus-ring-color)); outline-offset: var(--focus-ring-offset); } } diff --git a/core/scss/ui/_alerts.scss b/core/scss/ui/_alerts.scss index 8a1517129..1e8651126 100644 --- a/core/scss/ui/_alerts.scss +++ b/core/scss/ui/_alerts.scss @@ -61,6 +61,10 @@ .alert-link { font-weight: $alert-link-font-weight; color: var(--alert-link-color); + + &:hover { + color: var(--alert-link-color); + } } .alert-dismissible { diff --git a/core/scss/ui/_cards.scss b/core/scss/ui/_cards.scss index 46118773d..4698f62f5 100644 --- a/core/scss/ui/_cards.scss +++ b/core/scss/ui/_cards.scss @@ -405,9 +405,6 @@ Card optinos .card-options { --card-options-top: #{$card-options-top}; --card-options-inset: #{$card-options-inset}; - position: absolute; - inset-inline-end: var(--card-options-inset); - top: var(--card-options-top); display: flex; margin-inline-start: auto; } diff --git a/core/scss/ui/_datepicker.scss b/core/scss/ui/_datepicker.scss index 14fc60b96..4f975000f 100644 --- a/core/scss/ui/_datepicker.scss +++ b/core/scss/ui/_datepicker.scss @@ -37,15 +37,6 @@ font-family: inherit; font-size: var(--calendar-font-size); color: var(--calendar-color); - color-scheme: light dark; - - &[data-bs-theme='light'] { - color-scheme: light; - } - - &[data-bs-theme='dark'] { - color-scheme: dark; - } button { font-family: inherit; diff --git a/core/scss/ui/forms/_form-check.scss b/core/scss/ui/forms/_form-check.scss index babd6b69e..216df88a9 100644 --- a/core/scss/ui/forms/_form-check.scss +++ b/core/scss/ui/forms/_form-check.scss @@ -93,10 +93,11 @@ Form switch padding-inline-start: var(--form-switch-lg-padding-start); .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); height: var(--form-switch-lg-height); margin-inline-start: var(--form-switch-lg-input-offset); - background-size: var(--form-switch-lg-bg-size); } .form-check-label { diff --git a/docs/content/ui/getting-started/browser-support.mdx b/docs/content/ui/getting-started/browser-support.mdx index d3dd99fbf..31513155b 100644 --- a/docs/content/ui/getting-started/browser-support.mdx +++ b/docs/content/ui/getting-started/browser-support.mdx @@ -39,9 +39,12 @@ Feature|Used for|Chrome|Firefox|Safari `@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 `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. +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 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. diff --git a/docs/content/ui/getting-started/upgrade/1-5.mdx b/docs/content/ui/getting-started/upgrade/1-5.mdx index 3f9afeb76..291727b30 100644 --- a/docs/content/ui/getting-started/upgrade/1-5.mdx +++ b/docs/content/ui/getting-started/upgrade/1-5.mdx @@ -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) 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 +Have `.text-gray-*` classes in your markup|They color the text now - see [Gray text utilities](#gray-text-utilities) ## 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. - **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 +-

Last updated 3 hours ago

++

Last updated 3 hours ago

+``` + +For muted text, `.text-secondary` follows the theme and keeps enough contrast in dark mode. + ## 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: diff --git a/docs/content/ui/getting-started/upgrade/1-6.mdx b/docs/content/ui/getting-started/upgrade/1-6.mdx index aa68bec37..4b885d1f7 100644 --- a/docs/content/ui/getting-started/upgrade/1-6.mdx +++ b/docs/content/ui/getting-started/upgrade/1-6.mdx @@ -7,7 +7,7 @@ order: 1 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). @@ -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 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/litepicker` or call `new Litepicker(...)`|Switch to the `Datepicker` plugin, see [Litepicker is replaced by Datepicker](#litepicker-is-replaced-by-datepicker) +Use `` 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) 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) @@ -100,7 +104,7 @@ Component|Attribute|Docs `tabler.Autosize`|`data-bs-toggle="autosize"`|[Autosize](/ui/plugins/autosize) `tabler.CountUp`|`data-countup`|[Countup](/ui/plugins/countup) `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) 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 -`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 +-
    ++
      +``` ### 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. +### 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 +- ++ + +``` + +```sh +npm uninstall litepicker +npm install vanilla-calendar-pro +``` + +Then replace the script that created the picker with a data attribute: + +```diff +- +- ++ +``` + +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 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. +## 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 +- ++ +``` + +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 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. +## 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 ``: + +```diff +- ++ + + ++ +``` + +`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 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: +- [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. +- [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. - [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. @@ -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. - `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`. +- `.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. - The Bootstrap component port is compiled with strict TypeScript, so the type declarations in the package are tighter. Configs are typed per component. diff --git a/docs/content/ui/plugins/date-picker.mdx b/docs/content/ui/plugins/date-picker.mdx index a65822b37..598fd007f 100644 --- a/docs/content/ui/plugins/date-picker.mdx +++ b/docs/content/ui/plugins/date-picker.mdx @@ -31,6 +31,20 @@ The plugin itself ships in `tabler.js`. The calendar it renders comes from Vanil +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`: `} /> diff --git a/docs/content/ui/plugins/sortable.mdx b/docs/content/ui/plugins/sortable.mdx new file mode 100644 index 000000000..cecfff334 --- /dev/null +++ b/docs/content/ui/plugins/sortable.mdx @@ -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: + + + +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`: + +`} /> + +## Usage + +### Basic list + +Add `data-sortable` to the parent element. Its direct children become draggable. + + +
        +
      • Design the landing page
      • +
      • Write the release notes
      • +
      • Review open pull requests
      • +
      +
      + +### Options + +Pass [SortableJS options](https://github.com/SortableJS/Sortable#options) as JSON in the attribute. This list animates the items as they move. + + +
        +
      • First
      • +
      • Second
      • +
      • Third
      • +
      +
      + +### Drag handle + +Set `handle` to a selector to start the drag only from that element. The rest of the item stays clickable. + +```html +
        +
      • + ⋮⋮ + Design the landing page +
      • +
      +``` + +### Table rows + +Put `data-sortable` on the `tbody` to reorder the rows of a table. + +```html + + + + + +
      First row
      Second row
      +``` + +### 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 +
        + … +
      +``` + +## 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: + + + +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 + + diff --git a/docs/content/ui/plugins/vector-map.mdx b/docs/content/ui/plugins/vector-map.mdx index b2f87eaa6..8ffc2fc6f 100644 --- a/docs/content/ui/plugins/vector-map.mdx +++ b/docs/content/ui/plugins/vector-map.mdx @@ -28,7 +28,7 @@ Or include it from a CDN. You need three files: the stylesheet, the library, and + code={` `} /> diff --git a/preview/pages/sortable.astro b/preview/pages/sortable.astro index fe88a3ef8..c63595f31 100644 --- a/preview/pages/sortable.astro +++ b/preview/pages/sortable.astro @@ -1,5 +1,6 @@ --- import DefaultLayout from '@shared/layouts/DefaultLayout.astro' +import DocsLink from '@ui/DocsLink.astro' import Avatar from '@ui/Avatar.astro' import Card from '@ui/Card.astro' import CardHeader from '@ui/CardHeader.astro' @@ -50,6 +51,7 @@ const sections = [ --- +

      {sections[0]!.title}

      diff --git a/shared/data/docs.json b/shared/data/docs.json index 4db7df1f2..ba3a7ac42 100644 --- a/shared/data/docs.json +++ b/shared/data/docs.json @@ -500,6 +500,10 @@ "title": "Social icons", "url": "/ui/plugins/social-icons" }, + { + "title": "Sortable", + "url": "/ui/plugins/sortable" + }, { "title": "Theme base colors", "url": "/ui/plugins/theme-base-colors"