diff --git a/.agents/rules/main.mdc b/.agents/rules/main.mdc index 972df2148..a029865f8 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/` | +| `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/` | | `class-reference` | a docs page needs its `classnames` table added or refreshed | diff --git a/.agents/skills/bootstrap-component/SKILL.md b/.agents/skills/bootstrap-component/SKILL.md new file mode 100644 index 000000000..211828b9f --- /dev/null +++ b/.agents/skills/bootstrap-component/SKILL.md @@ -0,0 +1,224 @@ +--- +name: bootstrap-component +description: >- + Write a new Bootstrap-style JavaScript component class in `core/js/src/bootstrap/` — a `BaseComponent` subclass with typed config, `EVENT_*`/`CLASS_NAME_*` constants and a `data-bs-toggle` Data API. Use whenever the user asks for a new component in the Bootstrap port (a toggler, a dialog, a chip input, a stepper) or + wants an existing one brought up to this baseline. Covers file structure, TypeScript typing of `Default` and `DefaultType`, constructor and config merging, events, the Data API handler and the minimal public API. Not for the build, the tests or the size budget — that is the `core-js` skill. +--- + +# Rules for writing new components + +You are a master of JavaScript and TypeScript. + +The points below are the baseline for every new component class in `core/js/src/bootstrap/`. Read the `core-js` skill first for what that directory is (a vendored port of Bootstrap's JavaScript), how it is built, tested and size-budgeted. + +## 1. File structure + +In this order: + +1. Header with the MIT license comment and the file name (`Bootstrap toggler.ts`). +2. Imports: `BaseComponent`, `EventHandler`, optionally `Manipulator`, `SelectorEngine`, helpers from `./util/*`. +3. Types and interfaces at the top (`ComponentConfig`, `ComponentConfigInput`). +4. Constants: `NAME`, `DATA_KEY`, `EVENT_KEY`, selectors, classes, event names. +5. `Default` and `DefaultType` with TS types. +6. The component class. +7. "Data API implementation" section (when applicable). +8. Default export of the class. + +## 2. Typing + +- Define `type ComponentConfig` and `type ComponentConfigInput`: + + ```ts + type ComponentConfig = { + attribute: string + value: string | number | boolean | null + } + + type ComponentConfigInput = Partial & Record + ``` + +- Type `Default` as `ComponentConfig`. +- Type `DefaultType` as `Record`. +- In the class, declare: + - `declare _element: HTMLElement` (or a more specific element type). + - `declare _config: ComponentConfig`. + - `declare _isTransitioning: boolean` (when needed). +- Type method signatures explicitly (`show(): void`, `hide(): void`). +- `EventHandler.trigger(...)` can return `null` — use `?.defaultPrevented`. +- Use `Event` for handlers, and cast when you need specifics (`const keyboardEvent = event as KeyboardEvent`). `EventHandler.on` is generic, so `(event: KeyboardEvent) => …` type-checks without a cast; for `event.delegateTarget` cast to `DelegatedEvent` from `./dom/event-handler`. +- `core/tsconfig.json` is `strict` and `core/js/src` has no `any` at all, so a missing null check or an implicit `any` fails `pnpm run type-check`. Reach for `unknown` plus a type guard, or a small named type, instead of `any`, `[key: string]: any` or `as any`. +- Prefer modern syntax like `#private` class fields over the `private` keyword. + +## 3. Constructor and config + +- Call `super(element, config as Record)`. +- Read config from `Manipulator.getDataAttributes(...)` and merge with `Default`. `BaseComponent._getConfig` already does this through `_mergeConfigObj` → `_configAfterMerge` → `_typeCheckConfig`. +- If you need custom config processing, override `_getConfig` (or `_configAfterMerge`). +- After constructing, only set required fields and attach listeners. + +## 4. Events and CSS classes + +- Use constants in the format `EVENT_${NAME}` and `CLASS_NAME_*`. +- Build events with `EVENT_KEY` and `DATA_KEY` (as in `alert.ts`): + + ```ts + const NAME = 'toggler' + const DATA_KEY = 'bs.toggler' + const EVENT_KEY = `.${DATA_KEY}` + + const EVENT_TOGGLE = `toggle${EVENT_KEY}` + const EVENT_TOGGLED = `toggled${EVENT_KEY}` + const CLASS_NAME_SHOW = 'show' + ``` + +- Fire a cancellable `before` event, do the work, then fire the `after` event. +- Use only `EventHandler.on/off/one/trigger`. + +## 5. Data API + +- Selector in the form `[data-bs-toggle="name"]`. The attribute API stays `data-bs-*`; `data-tblr-*` is a 2.0 change (see `core-js`). +- Data API handler: + - check `A/AREA` and `event.preventDefault()`. + - use `SelectorEngine.getElementFromSelector`. + - `Name.getOrCreateInstance(target, config)` for data attributes. +- Use the helpers from `./util/component-functions` instead of writing the handler by hand: + - `eventActionOnPlugin(Component, 'click', SELECTOR_DATA_TOGGLE, 'toggle')` for a `data-bs-toggle` — resolves the targets (`data-bs-target` / `href`, or the trigger itself), skips `.disabled` / `:disabled`, prevents the default on `A/AREA`, and calls the method on `getOrCreateInstance` of every target. An optional + fifth argument receives `{ targets, event, instances }`. + - `enableDismissTrigger(Component, 'close')` for a `data-bs-dismiss`. + - Write the `EventHandler.on(document, ...)` handler yourself only when the trigger needs config from data attributes or other custom logic. + +## 6. Initialization and public API + +- Minimal public API: `show`, `hide`, `toggle`, `dispose`. +- If the component has animation state, use `_queueCallback` and `_isAnimated`. +- Expose `static get NAME`, `static get Default`, `static get DefaultType`. +- Export the class from `core/js/src/bootstrap.ts` (named export and the `bootstrap` namespace object), otherwise it never ships. + +## 7. Style and consistency + +- Keep style consistent with existing components (indentation, naming, comments, the `// Getters` / `// Public` / `// Private` section comments). +- Avoid direct DOM operations outside `EventHandler` and `SelectorEngine`, unless the component requires otherwise. + +## 8. Template + +```ts +/** + * -------------------------------------------------------------------------- + * Bootstrap toggler.ts + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + * -------------------------------------------------------------------------- + */ + +import BaseComponent from './base-component' +import EventHandler from './dom/event-handler' +import { eventActionOnPlugin } from './util/component-functions' + +type ComponentConfig = { + attribute: string + value: string | number | boolean | null +} + +type ComponentConfigInput = Partial & Record + +/** + * Constants + */ + +const NAME = 'toggler' +const DATA_KEY = 'bs.toggler' +const EVENT_KEY = `.${DATA_KEY}` + +const EVENT_TOGGLE = `toggle${EVENT_KEY}` +const EVENT_TOGGLED = `toggled${EVENT_KEY}` +const EVENT_CLICK = 'click' + +const SELECTOR_DATA_TOGGLE = '[data-bs-toggle="toggler"]' + +const DefaultType: Record = { + attribute: 'string', + value: '(string|number|boolean)', +} + +const Default: ComponentConfig = { + attribute: 'class', + value: null, +} + +/** + * Class definition + */ + +class Toggler extends BaseComponent { + declare _element: HTMLElement + declare _config: ComponentConfig + + constructor(element?: Element | string | null, config?: ComponentConfigInput | null) { + super(element as string | HTMLElement, (config ?? undefined) as Record) + } + + // Getters + static get Default(): ComponentConfig { + return Default + } + + static get DefaultType(): Record { + return DefaultType + } + + static get NAME(): string { + return NAME + } + + // Public + toggle(): void { + const toggleEvent = EventHandler.trigger(this._element, EVENT_TOGGLE) + + if (toggleEvent?.defaultPrevented) { + return + } + + this._execute() + + EventHandler.trigger(this._element, EVENT_TOGGLED) + } + + // Private + _execute(): void { + const { attribute, value } = this._config + + if (attribute === 'class') { + if (value) { + this._element.classList.toggle(String(value)) + } + return + } + + // Compare as strings since getAttribute() always returns a string + if (this._element.getAttribute(attribute) === String(value)) { + this._element.removeAttribute(attribute) + return + } + + this._element.setAttribute(attribute, String(value)) + } +} + +/** + * Data API implementation + */ + +eventActionOnPlugin(Toggler, EVENT_CLICK, SELECTOR_DATA_TOGGLE, 'toggle') + +export default Toggler +``` + +## 9. Checklist + +- [ ] File order as in §1, MIT header with the file name +- [ ] `ComponentConfig` / `ComponentConfigInput` defined; `Default` and `DefaultType` typed +- [ ] `declare _element` / `declare _config` in the class, explicit return types +- [ ] `EVENT_*` and `CLASS_NAME_*` constants, `?.defaultPrevented` on cancellable events +- [ ] Data API on `[data-bs-toggle="name"]` through `eventActionOnPlugin` (or a hand-written handler with the `A/AREA` guard and `getOrCreateInstance`) +- [ ] Public API limited to `show` / `hide` / `toggle` / `dispose` +- [ ] Exported from `core/js/src/bootstrap.ts` +- [ ] Spec in `js/tests/unit/`, and the `core-js` checklist (type-check, prettier, bundlewatch) diff --git a/.agents/skills/core-js/SKILL.md b/.agents/skills/core-js/SKILL.md index 4932bd207..7e79b7f8b 100644 --- a/.agents/skills/core-js/SKILL.md +++ b/.agents/skills/core-js/SKILL.md @@ -1,13 +1,8 @@ --- name: core-js description: >- - Work on the framework's own JavaScript in `core/js/` — the `tabler.js` and - `tabler-theme.js` bundles, the vendored Bootstrap component port, and their - browser tests. Use when a Tabler behaviour needs adding or fixing (a - `data-bs-toggle` handler, the theme switcher, a plugin initialiser), when a - Bootstrap component's JS misbehaves, and before changing anything under - `core/js/src/`. Not for demo-page scripts — that is the `astro-scripts` - skill. + Work on the framework's own JavaScript in `core/js/` — the `tabler.js` and `tabler-theme.js` bundles, the vendored Bootstrap component port, and their browser tests. Use when a Tabler behaviour needs adding or fixing (a `data-bs-toggle` handler, the theme switcher, a plugin initialiser), when a Bootstrap component's + JS misbehaves, and before changing anything under `core/js/src/`. Not for demo-page scripts — that is the `astro-scripts` skill. --- # The framework JavaScript @@ -16,10 +11,10 @@ description: >- ## 1. Two entry points -| Entry | Ships as | Holds | -| --- | --- | --- | -| `js/tabler.ts` | `tabler.js` / `.esm.js` (+ `.min`) | plugin initialisers, the Bootstrap components, the `tabler` helper namespace | -| `js/tabler-theme.ts` | `tabler-theme.js` (+ variants) | the colour-mode/theme switcher only | +| Entry | Ships as | Holds | +| -------------------- | ---------------------------------- | ---------------------------------------------------------------------------- | +| `js/tabler.ts` | `tabler.js` / `.esm.js` (+ `.min`) | plugin initialisers, the Bootstrap components, the `tabler` helper namespace | +| `js/tabler-theme.ts` | `tabler-theme.js` (+ variants) | the colour-mode/theme switcher only | `tabler-theme.js` is loaded right after `` and **not deferred**, so the chosen theme applies before the first paint. It stays tiny on purpose (bundlewatch: 1 kB raw, 800 B minified) — do not add anything to it that is not needed before paint. @@ -29,6 +24,7 @@ That directory is Bootstrap's JavaScript rewritten in TypeScript, MIT headers ke - Fix bugs the way upstream did, and keep the file's structure recognisable against `twbs/bootstrap`. Gratuitous restructuring makes the next upstream sync expensive. - `js/src/bootstrap.ts` is the single source of truth for what is exported and for the `bootstrap` namespace object. +- A new or reworked component class follows the `bootstrap-component` skill (file order, typed config, Data API). - Coverage is configured to measure exactly this directory (`js/src/bootstrap/**`), and `js/tests/unit/*.spec.ts` mirrors upstream's suite — a change here is expected to come with its test. ## 3. Tabler's own modules @@ -74,11 +70,11 @@ pnpm --filter @tabler/core test:js:coverage `vite` builds each entry as a library in `es` + `umd` (`BASE_NAME` selects the entry), then terser produces the `.min` variants with source maps. Sizes are enforced: -| File | Limit | -| --- | --- | -| `dist/js/tabler.js` | 64 kB | -| `dist/js/tabler.min.js` | 48 kB | -| `dist/js/tabler-theme.js` | 1 kB | +| File | Limit | +| ------------------------- | ----- | +| `dist/js/tabler.js` | 64 kB | +| `dist/js/tabler.min.js` | 48 kB | +| `dist/js/tabler-theme.js` | 1 kB | ```bash pnpm --filter @tabler/core build diff --git a/.changeset/strict-typescript-bootstrap-port.md b/.changeset/strict-typescript-bootstrap-port.md new file mode 100644 index 000000000..8076a3444 --- /dev/null +++ b/.changeset/strict-typescript-bootstrap-port.md @@ -0,0 +1,5 @@ +--- +"@tabler/core": patch +--- + +Updated the Bootstrap component port to strict TypeScript with typed `ComponentConfig` types and added `eventActionOnPlugin`. diff --git a/.gitignore b/.gitignore index 2b8fc5e77..90689d77c 100644 --- a/.gitignore +++ b/.gitignore @@ -49,6 +49,7 @@ sri.json # Test coverage coverage/ __screenshots__/ +.vitest-attachments/ __pycache__/ .claude/launch.json .claude/settings.local.json diff --git a/core/js/src/bootstrap/base-component.ts b/core/js/src/bootstrap/base-component.ts index 0ab39fcef..8ff8c38ad 100644 --- a/core/js/src/bootstrap/base-component.ts +++ b/core/js/src/bootstrap/base-component.ts @@ -58,7 +58,7 @@ class BaseComponent extends Config { } static getOrCreateInstance(element: ElementSelector, config: ComponentConfig = {}): BaseComponent { - return this.getInstance(element) || new this(element, typeof config === 'object' ? config : null) + return this.getInstance(element) || new this(element, typeof config === 'object' ? config : undefined) } static get VERSION(): string { diff --git a/core/js/src/bootstrap/button.ts b/core/js/src/bootstrap/button.ts index 15d7d310d..ea9fd2a61 100644 --- a/core/js/src/bootstrap/button.ts +++ b/core/js/src/bootstrap/button.ts @@ -18,6 +18,8 @@ const SELECTOR_DATA_TOGGLE = '[data-bs-toggle="button"], [data-tblr-toggle="butt const EVENT_CLICK_DATA_API = `click${EVENT_KEY}${DATA_API_KEY}` class Button extends BaseComponent { + declare _element: HTMLElement + static get NAME(): string { return NAME } diff --git a/core/js/src/bootstrap/carousel.ts b/core/js/src/bootstrap/carousel.ts index 39ebf1a52..d6657d1c1 100644 --- a/core/js/src/bootstrap/carousel.ts +++ b/core/js/src/bootstrap/carousel.ts @@ -11,7 +11,18 @@ import Manipulator from './dom/manipulator' import SelectorEngine from './dom/selector-engine' import { getNextActiveElement, isRTL, isVisible, reflow, triggerTransitionEnd } from './util/index' import Swipe from './util/swipe' -import type { ComponentConfig, ComponentConfigType } from './types' + +type ComponentConfig = { + interval: number | boolean + keyboard: boolean + pause: 'hover' | boolean + ride: boolean | 'carousel' + touch: boolean + wrap: boolean + defaultInterval?: number | boolean +} + +type ComponentConfigInput = Partial> & Record const NAME = 'carousel' const DATA_KEY = 'bs.carousel' @@ -66,7 +77,7 @@ const Default: ComponentConfig = { wrap: true, } -const DefaultType: ComponentConfigType = { +const DefaultType: Record, string> = { interval: '(number|boolean)', keyboard: 'boolean', pause: '(string|boolean)', @@ -76,6 +87,8 @@ const DefaultType: ComponentConfigType = { } class Carousel extends BaseComponent { + declare _element: HTMLElement + declare _config: ComponentConfig _interval: ReturnType | null _activeElement: HTMLElement | null _isSliding: boolean @@ -83,7 +96,7 @@ class Carousel extends BaseComponent { _swipeHelper: Swipe | null _indicatorsElement: HTMLElement | null - constructor(element: HTMLElement | string, config?: Partial) { + constructor(element: HTMLElement | string, config?: ComponentConfigInput) { super(element, config) this._interval = null @@ -104,7 +117,7 @@ class Carousel extends BaseComponent { return Default } - static get DefaultType(): ComponentConfigType { + static get DefaultType(): Record, string> { return DefaultType } @@ -273,7 +286,7 @@ class Carousel extends BaseComponent { const elementInterval = Number.parseInt(element.getAttribute('data-bs-interval') || element.getAttribute('data-tblr-interval') || '', 10) - this._config.interval = elementInterval || this._config.defaultInterval + this._config.interval = elementInterval || (this._config.defaultInterval ?? this._config.interval) } _slide(order: string, element: HTMLElement | null = null): void { @@ -302,7 +315,7 @@ class Carousel extends BaseComponent { const slideEvent = triggerEvent(EVENT_SLIDE) - if (slideEvent.defaultPrevented) { + if (slideEvent?.defaultPrevented) { return } diff --git a/core/js/src/bootstrap/collapse.ts b/core/js/src/bootstrap/collapse.ts index 4dc767fb9..0da932623 100644 --- a/core/js/src/bootstrap/collapse.ts +++ b/core/js/src/bootstrap/collapse.ts @@ -9,7 +9,15 @@ import BaseComponent from './base-component' import EventHandler from './dom/event-handler' import SelectorEngine from './dom/selector-engine' import { getElement, reflow } from './util/index' -import type { ComponentConfig, ComponentConfigType, ElementSelector } from './types' +import type { DelegatedEvent } from './dom/event-handler' +import type { ElementSelector } from './types' + +type ComponentConfig = { + parent: HTMLElement | null + toggle: boolean +} + +type ComponentConfigInput = Partial> & { parent?: HTMLElement | string | null } & Record const NAME = 'collapse' const DATA_KEY = 'bs.collapse' @@ -40,16 +48,18 @@ const Default: ComponentConfig = { toggle: true, } -const DefaultType: ComponentConfigType = { +const DefaultType: Record = { parent: '(null|element)', toggle: 'boolean', } class Collapse extends BaseComponent { + declare _element: HTMLElement + declare _config: ComponentConfig _isTransitioning: boolean _triggerArray: HTMLElement[] - constructor(element: ElementSelector, config?: ComponentConfig) { + constructor(element: ElementSelector, config?: ComponentConfigInput) { super(element, config) this._isTransitioning = false @@ -81,7 +91,7 @@ class Collapse extends BaseComponent { return Default } - static get DefaultType(): ComponentConfigType { + static get DefaultType(): Record { return DefaultType } @@ -240,7 +250,8 @@ class Collapse extends BaseComponent { } EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function (this: HTMLElement, event: Event) { - if ((event.target as HTMLElement).tagName === 'A' || ((event as any).delegateTarget && (event as any).delegateTarget.tagName === 'A')) { + const { delegateTarget } = event as DelegatedEvent + if ((event.target as HTMLElement).tagName === 'A' || (delegateTarget && delegateTarget.tagName === 'A')) { event.preventDefault() } diff --git a/core/js/src/bootstrap/dom/event-handler.ts b/core/js/src/bootstrap/dom/event-handler.ts index 7cc8276a5..41d1cd1bc 100644 --- a/core/js/src/bootstrap/dom/event-handler.ts +++ b/core/js/src/bootstrap/dom/event-handler.ts @@ -5,7 +5,10 @@ * -------------------------------------------------------------------------- */ -type EventCallback = (this: EventTarget, ...args: unknown[]) => void +// `this` is the element the handler was bound to, or the delegate target +export type EventCallback = (this: HTMLElement, event: E) => void + +export type DelegatedEvent = E & { delegateTarget: HTMLElement } interface BootstrapHandler { (event: Event): void @@ -99,7 +102,7 @@ function bootstrapHandler(element: EventTarget, fn: EventCallback): BootstrapHan EventHandler.off(element, event.type, fn) } - return fn.apply(element, [event]) + return fn.apply(element as HTMLElement, [event]) } as BootstrapHandler } @@ -119,7 +122,7 @@ function bootstrapDelegationHandler(element: EventTarget, selector: string, fn: EventHandler.off(element, event.type, selector, fn) } - return fn.apply(target, [event]) + return fn.apply(target as HTMLElement, [event]) } } } as BootstrapHandler @@ -129,7 +132,7 @@ function findHandler(events: Record, callable return Object.values(events).find((event) => event.callable === callable && event.delegationSelector === delegationSelector) } -function normalizeParameters(originalTypeEvent: string, handler: string | EventCallback | undefined, delegationFunction: EventCallback | undefined): [boolean, EventCallback, string] { +function normalizeParameters(originalTypeEvent: string, handler: string | false | EventCallback | undefined, delegationFunction: EventCallback | undefined): [boolean, EventCallback, string] { const isDelegated = typeof handler === 'string' const callable = isDelegated ? delegationFunction! : (handler || delegationFunction)! let typeEvent = getTypeEvent(originalTypeEvent) @@ -141,7 +144,7 @@ function normalizeParameters(originalTypeEvent: string, handler: string | EventC return [isDelegated, callable, typeEvent] } -function addHandler(element: EventTarget | null, originalTypeEvent: string, handler: string | EventCallback | undefined, delegationFunction: EventCallback | undefined, oneOff: boolean): void { +function addHandler(element: EventTarget | null, originalTypeEvent: string, handler: string | false | EventCallback | undefined, delegationFunction: EventCallback | undefined, oneOff: boolean): void { if (typeof originalTypeEvent !== 'string' || !element) { return } @@ -150,10 +153,10 @@ function addHandler(element: EventTarget | null, originalTypeEvent: string, hand if (originalTypeEvent in customEvents) { const wrapFunction = (fn: EventCallback): EventCallback => { - return function (this: EventTarget, event: unknown) { - const evt = event as MouseEvent & { delegateTarget: HTMLElement } + return function (this: EventTarget, event: Event) { + const evt = event as DelegatedEvent if (!evt.relatedTarget || (evt.relatedTarget !== evt.delegateTarget && !evt.delegateTarget.contains(evt.relatedTarget as Node))) { - return fn.call(this, event) + return fn.call(this as HTMLElement, event) } } } @@ -210,20 +213,20 @@ function getTypeEvent(event: string): string { } const EventHandler = { - on(element: EventTarget | null, event: string, handler: string | EventCallback, delegationFunction?: EventCallback): void { - addHandler(element, event, handler, delegationFunction, false) + on(element: EventTarget | null, event: string, handler: string | false | EventCallback, delegationFunction?: EventCallback): void { + addHandler(element, event, handler as string | false | EventCallback, delegationFunction as EventCallback | undefined, false) }, - one(element: EventTarget | null, event: string, handler: string | EventCallback, delegationFunction?: EventCallback): void { - addHandler(element, event, handler, delegationFunction, true) + one(element: EventTarget | null, event: string, handler: string | false | EventCallback, delegationFunction?: EventCallback): void { + addHandler(element, event, handler as string | false | EventCallback, delegationFunction as EventCallback | undefined, true) }, - off(element: EventTarget | null, originalTypeEvent: string, handler?: string | EventCallback, delegationFunction?: EventCallback): void { + off(element: EventTarget | null, originalTypeEvent: string, handler?: string | false | EventCallback, delegationFunction?: EventCallback): void { if (typeof originalTypeEvent !== 'string' || !element) { return } - const [isDelegated, callable, typeEvent] = normalizeParameters(originalTypeEvent, handler, delegationFunction) + const [isDelegated, callable, typeEvent] = normalizeParameters(originalTypeEvent, handler as string | false | EventCallback | undefined, delegationFunction as EventCallback | undefined) const inNamespace = typeEvent !== originalTypeEvent const events = getElementEvents(element) const storeElementEvent = events[typeEvent] || {} diff --git a/core/js/src/bootstrap/dropdown.ts b/core/js/src/bootstrap/dropdown.ts index 5b7e26cdc..267ea560f 100644 --- a/core/js/src/bootstrap/dropdown.ts +++ b/core/js/src/bootstrap/dropdown.ts @@ -11,7 +11,24 @@ import EventHandler from './dom/event-handler' import Manipulator from './dom/manipulator' import SelectorEngine from './dom/selector-engine' import { execute, getElement, getNextActiveElement, isDisabled, isElement, isRTL, isVisible, noop } from './util/index' -import type { ComponentConfig, ComponentConfigType } from './types' +import type { DelegatedEvent } from './dom/event-handler' + +type PopperOffsetData = { placement: Popper.Placement; reference: Popper.Rect; popper: Popper.Rect } +type PopperOffsetFunction = (popperData: PopperOffsetData) => number[] +type PopperConfigFunction = (defaultConfig: Partial) => Partial + +type ComponentConfig = { + autoClose: boolean | 'inside' | 'outside' + boundary: Popper.Boundary + display: 'dynamic' | 'static' + offset: number[] | string | ((popperData: PopperOffsetData, element: HTMLElement) => number[]) + popperConfig: Partial | PopperConfigFunction | null + reference: 'toggle' | 'parent' | HTMLElement | Popper.VirtualElement +} + +type ComponentConfigInput = Partial & Record + +type RelatedTarget = { relatedTarget: HTMLElement; clickEvent?: Event } const NAME = 'dropdown' const DATA_KEY = 'bs.dropdown' @@ -64,7 +81,7 @@ const Default: ComponentConfig = { reference: 'toggle', } -const DefaultType: ComponentConfigType = { +const DefaultType: Record = { autoClose: '(boolean|string)', boundary: '(string|element)', display: 'string', @@ -74,12 +91,14 @@ const DefaultType: ComponentConfigType = { } class Dropdown extends BaseComponent { + declare _element: HTMLElement + declare _config: ComponentConfig _popper: Popper.Instance | null _parent: HTMLElement _menu: HTMLElement _inNavbar: boolean - constructor(element: HTMLElement | string, config?: Partial) { + constructor(element: HTMLElement | string, config?: ComponentConfigInput) { super(element, config) this._popper = null @@ -92,7 +111,7 @@ class Dropdown extends BaseComponent { return Default } - static get DefaultType(): ComponentConfigType { + static get DefaultType(): Record { return DefaultType } @@ -115,14 +134,14 @@ class Dropdown extends BaseComponent { const showEvent = EventHandler.trigger(this._element, EVENT_SHOW, relatedTarget) - if (showEvent.defaultPrevented) { + if (showEvent?.defaultPrevented) { return } this._createPopper() if ('ontouchstart' in document.documentElement && !this._parent.closest(SELECTOR_NAVBAR_NAV)) { - for (const element of [].concat(...(document.body.children as any))) { + for (const element of Array.from(document.body.children)) { EventHandler.on(element, 'mouseover', noop) } } @@ -162,14 +181,14 @@ class Dropdown extends BaseComponent { } } - _completeHide(relatedTarget: Record): void { + _completeHide(relatedTarget: RelatedTarget): void { const hideEvent = EventHandler.trigger(this._element, EVENT_HIDE, relatedTarget) - if (hideEvent.defaultPrevented) { + if (hideEvent?.defaultPrevented) { return } if ('ontouchstart' in document.documentElement) { - for (const element of [].concat(...(document.body.children as any))) { + for (const element of Array.from(document.body.children)) { EventHandler.off(element, 'mouseover', noop) } } @@ -185,14 +204,14 @@ class Dropdown extends BaseComponent { EventHandler.trigger(this._element, EVENT_HIDDEN, relatedTarget) } - _getConfig(config: Partial): ComponentConfig { - config = super._getConfig(config) + _getConfig(config?: ComponentConfigInput): ComponentConfig { + const merged = super._getConfig(config) as ComponentConfig - if (typeof config.reference === 'object' && !isElement(config.reference) && typeof (config.reference as any).getBoundingClientRect !== 'function') { + if (typeof merged.reference === 'object' && !isElement(merged.reference) && typeof (merged.reference as Popper.VirtualElement).getBoundingClientRect !== 'function') { throw new TypeError(`${NAME.toUpperCase()}: Option "reference" provided type "object" without a required "getBoundingClientRect" method.`) } - return config + return merged } _createPopper(): void { @@ -250,7 +269,7 @@ class Dropdown extends BaseComponent { return this._element.closest(SELECTOR_NAVBAR) !== null } - _getOffset(): number[] | ((popperData: any) => number[]) { + _getOffset(): number[] | PopperOffsetFunction { const { offset } = this._config if (typeof offset === 'string') { @@ -258,10 +277,10 @@ class Dropdown extends BaseComponent { } if (typeof offset === 'function') { - return (popperData: any) => (offset as Function)(popperData, this._element) + return (popperData: PopperOffsetData) => offset(popperData, this._element) } - return offset as number[] + return offset } _getPopperConfig(): Partial { @@ -300,14 +319,15 @@ class Dropdown extends BaseComponent { } } - _selectMenuItem({ key, target }: { key: string; target: HTMLElement }): void { + _selectMenuItem({ key, target }: { key: string; target: EventTarget | null }): void { const items = SelectorEngine.find(SELECTOR_VISIBLE_ITEMS, this._menu).filter((element) => isVisible(element)) if (!items.length) { return } - getNextActiveElement(items, target, key === ARROW_DOWN_KEY, !items.includes(target)).focus() + const activeElement = target as HTMLElement + getNextActiveElement(items, activeElement, key === ARROW_DOWN_KEY, !items.includes(activeElement)).focus() } static clearMenus(event: Event & { button?: number; key?: string; composedPath?: () => EventTarget[] }): void { @@ -333,7 +353,7 @@ class Dropdown extends BaseComponent { continue } - const relatedTarget: Record = { relatedTarget: context._element } + const relatedTarget: RelatedTarget = { relatedTarget: context._element } if (event.type === 'click') { relatedTarget.clickEvent = event @@ -358,14 +378,14 @@ class Dropdown extends BaseComponent { event.preventDefault() - const getToggleButton = this.matches(SELECTOR_DATA_TOGGLE) ? this : SelectorEngine.prev(this, SELECTOR_DATA_TOGGLE)[0] || SelectorEngine.next(this, SELECTOR_DATA_TOGGLE)[0] || SelectorEngine.findOne(SELECTOR_DATA_TOGGLE, (event as any).delegateTarget.parentNode) + const getToggleButton = this.matches(SELECTOR_DATA_TOGGLE) ? this : SelectorEngine.prev(this, SELECTOR_DATA_TOGGLE)[0] || SelectorEngine.next(this, SELECTOR_DATA_TOGGLE)[0] || SelectorEngine.findOne(SELECTOR_DATA_TOGGLE, (event as DelegatedEvent).delegateTarget.parentNode as Element) const instance = Dropdown.getOrCreateInstance(getToggleButton!) as Dropdown if (isUpOrDownEvent) { event.stopPropagation() instance.show() - instance._selectMenuItem(event as any) + instance._selectMenuItem(event) return } diff --git a/core/js/src/bootstrap/modal.ts b/core/js/src/bootstrap/modal.ts index 2982e3944..92c22451d 100644 --- a/core/js/src/bootstrap/modal.ts +++ b/core/js/src/bootstrap/modal.ts @@ -45,13 +45,13 @@ const SELECTOR_DIALOG = '.modal-dialog' const SELECTOR_MODAL_BODY = '.modal-body' const SELECTOR_DATA_TOGGLE = '[data-bs-toggle="modal"], [data-tblr-toggle="modal"]' -interface ComponentConfig { - [key: string]: any +type ComponentConfig = { + backdrop: boolean | 'static' + focus: boolean + keyboard: boolean } -interface ComponentConfigType { - [key: string]: string -} +type ComponentConfigInput = Partial & Record const Default: ComponentConfig = { backdrop: true, @@ -59,7 +59,7 @@ const Default: ComponentConfig = { keyboard: true, } -const DefaultType: ComponentConfigType = { +const DefaultType: Record = { backdrop: '(boolean|string)', focus: 'boolean', keyboard: 'boolean', @@ -70,6 +70,8 @@ const DefaultType: ComponentConfigType = { */ class Modal extends BaseComponent { + declare _element: HTMLElement + declare _config: ComponentConfig _dialog: HTMLElement | null _backdrop: Backdrop _focustrap: FocusTrap @@ -77,7 +79,7 @@ class Modal extends BaseComponent { _isTransitioning: boolean _scrollBar: ScrollBarHelper - constructor(element: HTMLElement | string, config?: Partial) { + constructor(element: HTMLElement | string, config?: ComponentConfigInput) { super(element, config) this._dialog = SelectorEngine.findOne(SELECTOR_DIALOG, this._element) @@ -94,7 +96,7 @@ class Modal extends BaseComponent { return Default } - static get DefaultType(): ComponentConfigType { + static get DefaultType(): Record { return DefaultType } @@ -115,7 +117,7 @@ class Modal extends BaseComponent { relatedTarget, }) - if (showEvent.defaultPrevented) { + if (showEvent?.defaultPrevented) { return } @@ -138,7 +140,7 @@ class Modal extends BaseComponent { const hideEvent = EventHandler.trigger(this._element, EVENT_HIDE) - if (hideEvent.defaultPrevented) { + if (hideEvent?.defaultPrevented) { return } @@ -189,7 +191,7 @@ class Modal extends BaseComponent { this._element.setAttribute('role', 'dialog') this._element.scrollTop = 0 - const modalBody = SelectorEngine.findOne(SELECTOR_MODAL_BODY, this._dialog) + const modalBody = this._dialog ? SelectorEngine.findOne(SELECTOR_MODAL_BODY, this._dialog) : null if (modalBody) { modalBody.scrollTop = 0 } @@ -271,7 +273,7 @@ class Modal extends BaseComponent { _triggerBackdropTransition(): void { const hideEvent = EventHandler.trigger(this._element, EVENT_HIDE_PREVENTED) - if (hideEvent.defaultPrevented) { + if (hideEvent?.defaultPrevented) { return } @@ -329,8 +331,12 @@ EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function ( event.preventDefault() } + if (!target) { + return + } + EventHandler.one(target, EVENT_SHOW, (showEvent: Event) => { - if (showEvent.defaultPrevented) { + if (showEvent?.defaultPrevented) { return } diff --git a/core/js/src/bootstrap/offcanvas.ts b/core/js/src/bootstrap/offcanvas.ts index f52a535ed..6a609b688 100644 --- a/core/js/src/bootstrap/offcanvas.ts +++ b/core/js/src/bootstrap/offcanvas.ts @@ -42,13 +42,13 @@ const EVENT_KEYDOWN_DISMISS = `keydown.dismiss${EVENT_KEY}` const SELECTOR_DATA_TOGGLE = '[data-bs-toggle="offcanvas"], [data-tblr-toggle="offcanvas"]' -interface ComponentConfig { - [key: string]: any +type ComponentConfig = { + backdrop: boolean | 'static' + keyboard: boolean + scroll: boolean } -interface ComponentConfigType { - [key: string]: string -} +type ComponentConfigInput = Partial & Record const Default: ComponentConfig = { backdrop: true, @@ -56,7 +56,7 @@ const Default: ComponentConfig = { scroll: false, } -const DefaultType: ComponentConfigType = { +const DefaultType: Record = { backdrop: '(boolean|string)', keyboard: 'boolean', scroll: 'boolean', @@ -67,11 +67,13 @@ const DefaultType: ComponentConfigType = { */ class Offcanvas extends BaseComponent { + declare _element: HTMLElement + declare _config: ComponentConfig _isShown: boolean _backdrop: Backdrop _focustrap: FocusTrap - constructor(element: HTMLElement | string, config?: Partial) { + constructor(element: HTMLElement | string, config?: ComponentConfigInput) { super(element, config) this._isShown = false @@ -84,7 +86,7 @@ class Offcanvas extends BaseComponent { return Default } - static get DefaultType(): ComponentConfigType { + static get DefaultType(): Record { return DefaultType } @@ -103,7 +105,7 @@ class Offcanvas extends BaseComponent { const showEvent = EventHandler.trigger(this._element, EVENT_SHOW, { relatedTarget }) - if (showEvent.defaultPrevented) { + if (showEvent?.defaultPrevented) { return } @@ -138,7 +140,7 @@ class Offcanvas extends BaseComponent { const hideEvent = EventHandler.trigger(this._element, EVENT_HIDE) - if (hideEvent.defaultPrevented) { + if (hideEvent?.defaultPrevented) { return } @@ -223,6 +225,10 @@ EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function ( event.preventDefault() } + if (!target) { + return + } + if (isDisabled(this)) { return } diff --git a/core/js/src/bootstrap/popover.ts b/core/js/src/bootstrap/popover.ts index 578eff266..eac70133d 100644 --- a/core/js/src/bootstrap/popover.ts +++ b/core/js/src/bootstrap/popover.ts @@ -6,6 +6,7 @@ */ import Tooltip from './tooltip' +import type { TooltipConfig, TooltipContent, TooltipContentMap } from './tooltip' /** * Constants @@ -16,13 +17,11 @@ const NAME = 'popover' const SELECTOR_TITLE = '.popover-header' const SELECTOR_CONTENT = '.popover-body' -interface ComponentConfig { - [key: string]: any +type ComponentConfig = TooltipConfig & { + content: TooltipContent } -interface ComponentConfigType { - [key: string]: string -} +type ComponentConfigInput = Partial & Record const Default: ComponentConfig = { ...Tooltip.Default, @@ -33,7 +32,7 @@ const Default: ComponentConfig = { trigger: 'click', } -const DefaultType: ComponentConfigType = { +const DefaultType: Record = { ...Tooltip.DefaultType, content: '(null|string|element|function)', } @@ -43,11 +42,18 @@ const DefaultType: ComponentConfigType = { */ class Popover extends Tooltip { + declare _element: HTMLElement + declare _config: ComponentConfig + + constructor(element: HTMLElement | string, config?: ComponentConfigInput) { + super(element, config) + } + static get Default(): ComponentConfig { return Default } - static get DefaultType(): ComponentConfigType { + static get DefaultType(): Record { return DefaultType } @@ -59,14 +65,14 @@ class Popover extends Tooltip { return Boolean(this._getTitle() || this._getContent()) } - _getContentForTemplate(): Record { + _getContentForTemplate(): TooltipContentMap { return { [SELECTOR_TITLE]: this._getTitle(), [SELECTOR_CONTENT]: this._getContent(), } } - _getContent(): any { + _getContent(): string | HTMLElement | null { return this._resolvePossibleFunction(this._config.content) } } diff --git a/core/js/src/bootstrap/scrollspy.ts b/core/js/src/bootstrap/scrollspy.ts index 33c530f2a..07d3a8bfa 100644 --- a/core/js/src/bootstrap/scrollspy.ts +++ b/core/js/src/bootstrap/scrollspy.ts @@ -9,7 +9,16 @@ import BaseComponent from './base-component' import EventHandler from './dom/event-handler' import SelectorEngine from './dom/selector-engine' import { getElement, isDisabled, isVisible, parseSelector } from './util/index' -import type { ComponentConfig, ComponentConfigType } from './types' + +type ComponentConfig = { + offset: number | null + rootMargin: string + smoothScroll: boolean + target: HTMLElement | null + threshold: number[] +} + +type ComponentConfigInput = Partial> & { target?: HTMLElement | string | null; threshold?: number[] | string } & Record const NAME = 'scrollspy' const DATA_KEY = 'bs.scrollspy' @@ -41,7 +50,7 @@ const Default: ComponentConfig = { threshold: [0.1, 0.5, 1], } -const DefaultType: ComponentConfigType = { +const DefaultType: Record = { offset: '(number|null)', rootMargin: 'string', smoothScroll: 'boolean', @@ -50,6 +59,8 @@ const DefaultType: ComponentConfigType = { } class ScrollSpy extends BaseComponent { + declare _element: HTMLElement + declare _config: ComponentConfig _targetLinks: Map _observableSections: Map _rootElement: HTMLElement | null @@ -60,7 +71,7 @@ class ScrollSpy extends BaseComponent { parentScrollTop: number } - constructor(element: HTMLElement | string, config?: Partial) { + constructor(element: HTMLElement | string, config?: ComponentConfigInput) { super(element, config) this._targetLinks = new Map() @@ -79,7 +90,7 @@ class ScrollSpy extends BaseComponent { return Default } - static get DefaultType(): ComponentConfigType { + static get DefaultType(): Record { return DefaultType } @@ -112,8 +123,9 @@ class ScrollSpy extends BaseComponent { config.rootMargin = config.offset ? `${config.offset}px 0px -30%` : config.rootMargin - if (typeof config.threshold === 'string') { - config.threshold = config.threshold.split(',').map((value: string) => Number.parseFloat(value)) + const threshold: unknown = config.threshold + if (typeof threshold === 'string') { + config.threshold = threshold.split(',').map((value: string) => Number.parseFloat(value)) } return config diff --git a/core/js/src/bootstrap/toast.ts b/core/js/src/bootstrap/toast.ts index ee51ffc38..331d4eec3 100644 --- a/core/js/src/bootstrap/toast.ts +++ b/core/js/src/bootstrap/toast.ts @@ -9,7 +9,15 @@ import BaseComponent from './base-component' import EventHandler from './dom/event-handler.js' import { enableDismissTrigger } from './util/component-functions' import { reflow } from './util/index.js' -import type { ComponentConfig, ComponentConfigType, ElementSelector } from './types' +import type { ElementSelector } from './types' + +type ComponentConfig = { + animation: boolean + autohide: boolean + delay: number +} + +type ComponentConfigInput = Partial & Record const NAME = 'toast' const DATA_KEY = 'bs.toast' @@ -29,7 +37,7 @@ const CLASS_NAME_HIDE = 'hide' const CLASS_NAME_SHOW = 'show' const CLASS_NAME_SHOWING = 'showing' -const DefaultType: ComponentConfigType = { +const DefaultType: Record = { animation: 'boolean', autohide: 'boolean', delay: 'number', @@ -42,11 +50,13 @@ const Default: ComponentConfig = { } class Toast extends BaseComponent { + declare _element: HTMLElement + declare _config: ComponentConfig _timeout: ReturnType | null _hasMouseInteraction: boolean _hasKeyboardInteraction: boolean - constructor(element: ElementSelector, config?: ComponentConfig) { + constructor(element: ElementSelector, config?: ComponentConfigInput) { super(element, config) this._timeout = null @@ -59,7 +69,7 @@ class Toast extends BaseComponent { return Default } - static get DefaultType(): ComponentConfigType { + static get DefaultType(): Record { return DefaultType } diff --git a/core/js/src/bootstrap/tooltip.ts b/core/js/src/bootstrap/tooltip.ts index acdd9bc52..a8c7e3fe0 100644 --- a/core/js/src/bootstrap/tooltip.ts +++ b/core/js/src/bootstrap/tooltip.ts @@ -12,6 +12,7 @@ import Manipulator from './dom/manipulator' import { execute, findShadowRoot, getElement, getUID, isRTL, noop } from './util/index' import { DefaultAllowlist } from './util/sanitizer' import TemplateFactory from './util/template-factory' +import type { AllowList, SanitizeFn } from './types' /** * Constants @@ -45,13 +46,41 @@ const EVENT_FOCUSOUT = 'focusout' const EVENT_MOUSEENTER = 'mouseenter' const EVENT_MOUSELEAVE = 'mouseleave' -interface ComponentConfig { - [key: string]: any +type PopperOffsetData = { placement: Popper.Placement; reference: Popper.Rect; popper: Popper.Rect } +type PopperOffsetFunction = (popperData: PopperOffsetData) => number[] +type PopperConfigFunction = (defaultConfig: Partial) => Partial + +export type TooltipDelay = number | { show: number; hide: number } +export type TooltipContent = string | HTMLElement | null | ((this: HTMLElement, element: HTMLElement) => string | HTMLElement | null) +// Keys are selectors inside the template, values the content for that slot +export type TooltipContentMap = Record +export type TooltipPlacement = string | ((this: Tooltip, tip: HTMLElement, element: HTMLElement) => string) + +export type TooltipConfig = { + allowList: AllowList + animation: boolean + boundary: Popper.Boundary + container: HTMLElement | string | false + customClass: string | ((this: HTMLElement, element: HTMLElement) => string) + delay: TooltipDelay + fallbackPlacements: string[] + html: boolean + offset: number[] | string | ((popperData: PopperOffsetData, element: HTMLElement) => number[]) + placement: TooltipPlacement + popperConfig: Partial | PopperConfigFunction | null + sanitize: boolean + sanitizeFn: SanitizeFn | null + selector: string | false + template: string + title: TooltipContent + trigger: string } -interface ComponentConfigType { - [key: string]: string -} +type ComponentConfig = TooltipConfig + +export type TooltipConfigInput = Partial & Record + +type ComponentConfigInput = TooltipConfigInput const AttachmentMap: Record = { AUTO: 'auto', @@ -81,7 +110,7 @@ const Default: ComponentConfig = { trigger: 'hover focus', } -const DefaultType: ComponentConfigType = { +const DefaultType: Record = { allowList: 'object', animation: 'boolean', boundary: '(string|element)', @@ -106,17 +135,19 @@ const DefaultType: ComponentConfigType = { */ class Tooltip extends BaseComponent { + declare _element: HTMLElement + declare _config: ComponentConfig _isEnabled: boolean _timeout: ReturnType | number _isHovered: boolean | null _activeTrigger: Record _popper: Popper.Instance | null _templateFactory: TemplateFactory | null - _newContent: Record | null + _newContent: TooltipContentMap | null tip: HTMLElement | null - _hideModalHandler: (() => void) | null + _hideModalHandler: () => void - constructor(element: HTMLElement | string, config?: Partial) { + constructor(element: HTMLElement | string, config?: ComponentConfigInput) { if (typeof Popper === 'undefined') { throw new TypeError("Bootstrap's tooltips require Popper (https://popper.js.org/docs/v2/)") } @@ -132,7 +163,11 @@ class Tooltip extends BaseComponent { this._newContent = null this.tip = null - this._hideModalHandler = null + this._hideModalHandler = () => { + if (this._element) { + this.hide() + } + } this._setListeners() @@ -145,7 +180,7 @@ class Tooltip extends BaseComponent { return Default } - static get DefaultType(): ComponentConfigType { + static get DefaultType(): Record { return DefaultType } @@ -204,29 +239,30 @@ class Tooltip extends BaseComponent { const shadowRoot = findShadowRoot(this._element) const isInTheDom = (shadowRoot || this._element.ownerDocument.documentElement).contains(this._element) - if (showEvent.defaultPrevented || !isInTheDom) { + if (showEvent?.defaultPrevented || !isInTheDom) { return } this._disposePopper() - const tip = this._getTipElement() + const tip = this._getTipElement()! - this._element.setAttribute('aria-describedby', tip!.getAttribute('id')!) + this._element.setAttribute('aria-describedby', tip.getAttribute('id')!) const { container } = this._config if (!this._element.ownerDocument.documentElement.contains(this.tip)) { - container.append(tip) + // `_configAfterMerge` resolved the container to an element + ;(container as HTMLElement).append(tip) EventHandler.trigger(this._element, (this.constructor as typeof Tooltip).eventName(EVENT_INSERTED)) } - this._popper = this._createPopper(tip!) + this._popper = this._createPopper(tip) tip!.classList.add(CLASS_NAME_SHOW) if ('ontouchstart' in document.documentElement) { - for (const element of [].concat(...(document.body.children as any))) { + for (const element of Array.from(document.body.children)) { EventHandler.on(element, 'mouseover', noop) } } @@ -250,7 +286,7 @@ class Tooltip extends BaseComponent { } const hideEvent = EventHandler.trigger(this._element, (this.constructor as typeof Tooltip).eventName(EVENT_HIDE)) - if (hideEvent.defaultPrevented) { + if (hideEvent?.defaultPrevented) { return } @@ -258,7 +294,7 @@ class Tooltip extends BaseComponent { tip!.classList.remove(CLASS_NAME_SHOW) if ('ontouchstart' in document.documentElement) { - for (const element of [].concat(...(document.body.children as any))) { + for (const element of Array.from(document.body.children)) { EventHandler.off(element, 'mouseover', noop) } } @@ -302,7 +338,7 @@ class Tooltip extends BaseComponent { return this.tip } - _createTipElement(content: Record): HTMLElement | null { + _createTipElement(content: TooltipContentMap): HTMLElement | null { const tip = this._getTemplateFactory(content).toHtml() as HTMLElement if (!tip) { @@ -323,7 +359,7 @@ class Tooltip extends BaseComponent { return tip } - setContent(content: Record): void { + setContent(content: TooltipContentMap): void { this._newContent = content if (this._isShown()) { this._disposePopper() @@ -331,7 +367,7 @@ class Tooltip extends BaseComponent { } } - _getTemplateFactory(content: Record): TemplateFactory { + _getTemplateFactory(content: TooltipContentMap): TemplateFactory { if (this._templateFactory) { this._templateFactory.changeContent(content) } else { @@ -345,13 +381,13 @@ class Tooltip extends BaseComponent { return this._templateFactory } - _getContentForTemplate(): Record { + _getContentForTemplate(): TooltipContentMap { return { [SELECTOR_TOOLTIP_INNER]: this._getTitle(), } } - _getTitle(): string { + _getTitle(): string | HTMLElement { return this._resolvePossibleFunction(this._config.title) || this._element.getAttribute('data-bs-original-title') || this._element.getAttribute('data-tblr-original-title') || '' } @@ -373,7 +409,7 @@ class Tooltip extends BaseComponent { return Popper.createPopper(this._element, tip, this._getPopperConfig(attachment)) } - _getOffset(): number[] | ((popperData: any) => number[]) { + _getOffset(): number[] | PopperOffsetFunction { const { offset } = this._config if (typeof offset === 'string') { @@ -381,14 +417,14 @@ class Tooltip extends BaseComponent { } if (typeof offset === 'function') { - return (popperData: any) => (offset as Function)(popperData, this._element) + return (popperData: PopperOffsetData) => offset(popperData, this._element) } - return offset as number[] + return offset } - _resolvePossibleFunction(arg: any): any { - return execute(arg, [this._element, this._element]) + _resolvePossibleFunction(arg: T | ((this: HTMLElement, element: HTMLElement) => T)): T { + return execute(arg, [this._element, this._element]) as T } _getPopperConfig(attachment: string): Partial { @@ -423,7 +459,7 @@ class Tooltip extends BaseComponent { name: 'preSetPlacement', enabled: true, phase: 'beforeMain', - fn: (data: any) => { + fn: (data: Popper.ModifierArguments>) => { this._getTipElement()!.setAttribute('data-popper-placement', data.state.placement) }, }, @@ -465,12 +501,6 @@ class Tooltip extends BaseComponent { } } - this._hideModalHandler = () => { - if (this._element) { - this.hide() - } - } - EventHandler.on(this._element.closest(SELECTOR_MODAL), EVENT_MODAL_HIDE, this._hideModalHandler) } @@ -501,7 +531,7 @@ class Tooltip extends BaseComponent { if (this._isHovered) { this.show() } - }, this._config.delay.show) + }, this._getDelay().show) } _leave(): void { @@ -515,7 +545,12 @@ class Tooltip extends BaseComponent { if (!this._isHovered) { this.hide() } - }, this._config.delay.hide) + }, this._getDelay().hide) + } + + _getDelay(): { show: number; hide: number } { + const { delay } = this._config + return typeof delay === 'number' ? { show: delay, hide: delay } : delay } _setTimeout(handler: () => void, timeout: number): void { @@ -527,7 +562,7 @@ class Tooltip extends BaseComponent { return Object.values(this._activeTrigger).includes(true) } - _getConfig(config: Partial): ComponentConfig { + _getConfig(config?: ComponentConfigInput): ComponentConfig { const dataAttributes = Manipulator.getDataAttributes(this._element) for (const dataAttribute of Object.keys(dataAttributes)) { @@ -536,18 +571,18 @@ class Tooltip extends BaseComponent { } } - config = { + let merged: Record = { ...dataAttributes, ...(typeof config === 'object' && config ? config : {}), } - config = this._mergeConfigObj(config) - config = this._configAfterMerge(config) - this._typeCheckConfig(config) - return config + merged = this._mergeConfigObj(merged) + merged = this._configAfterMerge(merged as ComponentConfig) + this._typeCheckConfig(merged) + return merged as ComponentConfig } _configAfterMerge(config: ComponentConfig): ComponentConfig { - config.container = config.container === false ? document.body : getElement(config.container) + config.container = config.container === false ? document.body : (getElement(config.container) as HTMLElement) if (typeof config.delay === 'number') { config.delay = { @@ -556,22 +591,25 @@ class Tooltip extends BaseComponent { } } - if (typeof config.title === 'number') { - config.title = config.title.toString() + // Numbers are accepted at runtime (`data-bs-title="42"`) even though the type says string + const raw = config as Record + if (typeof raw.title === 'number') { + raw.title = raw.title.toString() } - if (typeof config.content === 'number') { - config.content = config.content.toString() + if (typeof raw.content === 'number') { + raw.content = raw.content.toString() } return config } - _getDelegateConfig(): Partial { - const config: Partial = {} + _getDelegateConfig(): ComponentConfigInput { + const config: Record = {} + const defaults = (this.constructor as typeof Tooltip).Default as Record for (const [key, value] of Object.entries(this._config)) { - if ((this.constructor as typeof Tooltip).Default[key] !== value) { + if (defaults[key] !== value) { config[key] = value } } diff --git a/core/js/src/bootstrap/types.ts b/core/js/src/bootstrap/types.ts index 48d3d056d..467c34c45 100644 --- a/core/js/src/bootstrap/types.ts +++ b/core/js/src/bootstrap/types.ts @@ -5,7 +5,7 @@ * -------------------------------------------------------------------------- */ -export type ComponentConfig = Record +export type ComponentConfig = Record export type ComponentConfigType = Record diff --git a/core/js/src/bootstrap/util/backdrop.ts b/core/js/src/bootstrap/util/backdrop.ts index 71723ec00..ad2faaa42 100644 --- a/core/js/src/bootstrap/util/backdrop.ts +++ b/core/js/src/bootstrap/util/backdrop.ts @@ -15,7 +15,7 @@ const CLASS_NAME_FADE = 'fade' const CLASS_NAME_SHOW = 'show' const EVENT_MOUSEDOWN = `mousedown.bs.${NAME}` -interface BackdropConfig { +type BackdropConfig = { className: string clickCallback: (() => void) | null isAnimated: boolean diff --git a/core/js/src/bootstrap/util/component-functions.ts b/core/js/src/bootstrap/util/component-functions.ts index 8d87fd4c6..48dee659c 100644 --- a/core/js/src/bootstrap/util/component-functions.ts +++ b/core/js/src/bootstrap/util/component-functions.ts @@ -9,11 +9,34 @@ import EventHandler from '../dom/event-handler.js' import SelectorEngine from '../dom/selector-engine.js' import { isDisabled } from './index' +type ComponentInstance = unknown + +interface PluginComponent { + NAME: string + getOrCreateInstance(element: HTMLElement | string | null): ComponentInstance +} + +interface EventActionData { + targets: HTMLElement[] + event: Event +} + +interface PluginEventActionData extends EventActionData { + instances: ComponentInstance[] +} + interface DismissibleComponent { EVENT_KEY: string NAME: string - // eslint-disable-next-line @typescript-eslint/no-explicit-any - getOrCreateInstance(element: HTMLElement | string | null): any + getOrCreateInstance(element: HTMLElement | string | null): ComponentInstance +} + +const callMethod = (instance: ComponentInstance, method: string): void => { + const callable = (instance as Record)[method] + + if (typeof callable === 'function') { + callable.call(instance) + } } const enableDismissTrigger = (component: DismissibleComponent, method = 'hide'): void => { @@ -30,10 +53,37 @@ const enableDismissTrigger = (component: DismissibleComponent, method = 'hide'): } const target = SelectorEngine.getElementFromSelector(this) || this.closest(`.${name}`) - const instance = component.getOrCreateInstance(target) - - instance[method]() + callMethod(component.getOrCreateInstance(target), method) }) } -export { enableDismissTrigger } +const eventAction = (onEvent: string, stringSelector: string, callback: (data: EventActionData) => void): void => { + const selector = `${stringSelector}:not(.disabled):not(:disabled)` + + EventHandler.on(document, onEvent, selector, function (this: HTMLElement, event: Event) { + if (['A', 'AREA'].includes(this.tagName)) { + event.preventDefault() + } + + const targetSelector = SelectorEngine.getSelectorFromElement(this) + const targets = targetSelector ? SelectorEngine.find(targetSelector) : [this] + + callback({ targets, event }) + }) +} + +const eventActionOnPlugin = (Plugin: PluginComponent, onEvent: string, stringSelector: string, method: string, callback: ((data: PluginEventActionData) => void) | null = null): void => { + eventAction(`${onEvent}.${Plugin.NAME}`, stringSelector, (data) => { + const instances = data.targets.filter(Boolean).map((element) => Plugin.getOrCreateInstance(element)) + + if (typeof callback === 'function') { + callback({ ...data, instances }) + } + + for (const instance of instances) { + callMethod(instance, method) + } + }) +} + +export { enableDismissTrigger, eventAction, eventActionOnPlugin } diff --git a/core/js/src/bootstrap/util/focustrap.ts b/core/js/src/bootstrap/util/focustrap.ts index e75d98e80..07010f202 100644 --- a/core/js/src/bootstrap/util/focustrap.ts +++ b/core/js/src/bootstrap/util/focustrap.ts @@ -20,7 +20,7 @@ const TAB_KEY = 'Tab' const TAB_NAV_FORWARD = 'forward' const TAB_NAV_BACKWARD = 'backward' -interface FocusTrapConfig { +type FocusTrapConfig = { autofocus: boolean trapElement: HTMLElement | null } @@ -87,7 +87,7 @@ class FocusTrap extends Config { _handleFocusin(event: FocusEvent): void { const { trapElement } = this._config - if (event.target === document || event.target === trapElement || trapElement!.contains(event.target as Node)) { + if (!trapElement || event.target === document || event.target === trapElement || trapElement.contains(event.target as Node)) { return } diff --git a/core/js/src/bootstrap/util/index.ts b/core/js/src/bootstrap/util/index.ts index c893b3c35..438dff2eb 100644 --- a/core/js/src/bootstrap/util/index.ts +++ b/core/js/src/bootstrap/util/index.ts @@ -10,7 +10,7 @@ const MILLISECONDS_MULTIPLIER = 1000 const TRANSITION_END = 'transitionend' const parseSelector = (selector: string): string => { - if (selector && window.CSS && window.CSS.escape) { + if (selector && typeof CSS !== 'undefined' && typeof CSS.escape === 'function') { selector = selector.replace(/#([^\s"#']+)/g, (match, id) => `#${CSS.escape(id)}`) } diff --git a/core/js/src/bootstrap/util/swipe.ts b/core/js/src/bootstrap/util/swipe.ts index cb27d4fa5..6f07d88f7 100644 --- a/core/js/src/bootstrap/util/swipe.ts +++ b/core/js/src/bootstrap/util/swipe.ts @@ -22,7 +22,7 @@ const POINTER_TYPE_PEN = 'pen' const CLASS_NAME_POINTER_EVENT = 'pointer-event' const SWIPE_THRESHOLD = 40 -interface SwipeConfig { +type SwipeConfig = { endCallback: (() => void) | null leftCallback: (() => void) | null rightCallback: (() => void) | null @@ -43,8 +43,8 @@ const DefaultType: ComponentConfigType = { class Swipe extends Config { declare _config: SwipeConfig & ComponentConfig _element: HTMLElement - _deltaX: number - _supportPointerEvents: boolean + declare _deltaX: number + declare _supportPointerEvents: boolean constructor(element: HTMLElement, config?: ComponentConfig) { super() diff --git a/core/js/src/bootstrap/util/template-factory.ts b/core/js/src/bootstrap/util/template-factory.ts index f77058d12..b5a54fd05 100644 --- a/core/js/src/bootstrap/util/template-factory.ts +++ b/core/js/src/bootstrap/util/template-factory.ts @@ -13,7 +13,7 @@ import type { AllowList, ComponentConfig, ComponentConfigType, SanitizeFn } from const NAME = 'TemplateFactory' -interface TemplateFactoryConfig { +type TemplateFactoryConfig = { allowList: AllowList content: Record extraClass: string | (() => string) @@ -104,7 +104,7 @@ class TemplateFactory extends Config { _typeCheckConfig(config: ComponentConfig): void { super._typeCheckConfig(config) - this._checkContent(config.content) + this._checkContent(config.content as Record) } _checkContent(arg: Record): void { diff --git a/core/js/src/countup.ts b/core/js/src/countup.ts index 1df871293..c6519ece7 100644 --- a/core/js/src/countup.ts +++ b/core/js/src/countup.ts @@ -3,7 +3,7 @@ const countupElements: NodeListOf = document.querySelectorAll = {} + let options: Record = {} try { const dataOptions = element.getAttribute('data-countup') ? JSON.parse(element.getAttribute('data-countup')!) : {} options = Object.assign( diff --git a/core/js/src/dropdown.ts b/core/js/src/dropdown.ts index 74d57e634..0df65a649 100644 --- a/core/js/src/dropdown.ts +++ b/core/js/src/dropdown.ts @@ -4,7 +4,7 @@ import { Dropdown } from './bootstrap' const dropdownTriggerList: HTMLElement[] = [].slice.call(document.querySelectorAll('[data-bs-toggle="dropdown"]')) dropdownTriggerList.map(function (dropdownTriggerEl: HTMLElement) { const options = { - boundary: dropdownTriggerEl.getAttribute('data-bs-boundary') === 'viewport' ? document.documentElement : 'clippingParents', + boundary: dropdownTriggerEl.getAttribute('data-bs-boundary') === 'viewport' ? document.documentElement : ('clippingParents' as const), } return new Dropdown(dropdownTriggerEl, options) }) diff --git a/core/js/src/global.d.ts b/core/js/src/global.d.ts index 466e68ec3..a5373ac49 100644 --- a/core/js/src/global.d.ts +++ b/core/js/src/global.d.ts @@ -4,12 +4,12 @@ interface Window { CountUp: new ( target: HTMLElement, endVal: number, - options?: any, + options?: Record, ) => { error: boolean start: () => void } } - IMask?: new (element: HTMLElement, options: { mask: string; lazy?: boolean }) => any - Sortable?: new (element: HTMLElement, options?: any) => any + IMask?: new (element: HTMLElement, options: { mask: string; lazy?: boolean }) => unknown + Sortable?: new (element: HTMLElement, options?: Record) => unknown } diff --git a/core/js/src/input-mask.ts b/core/js/src/input-mask.ts index 7d6e6a298..f51e95bd0 100644 --- a/core/js/src/input-mask.ts +++ b/core/js/src/input-mask.ts @@ -3,7 +3,7 @@ const maskElementList: HTMLElement[] = [].slice.call(document.querySelectorAll = document.querySelectorAll = {} + let options: Record = {} try { const rawOptions = element.getAttribute('data-sortable') diff --git a/core/js/tabler-theme.ts b/core/js/tabler-theme.ts index 6fd23f637..4e9da2eec 100644 --- a/core/js/tabler-theme.ts +++ b/core/js/tabler-theme.ts @@ -5,14 +5,12 @@ */ import { themeDefaults, type ThemeKey } from './src/theme-config' -const params = new Proxy(new URLSearchParams(window.location.search), { - get: (searchParams: URLSearchParams, prop: string): string | null => searchParams.get(prop), -}) +const params = new URLSearchParams(window.location.search) const prefersDark = window.matchMedia('(prefers-color-scheme: dark)') for (const key in themeDefaults) { - const param = params[key] + const param = params.get(key) let selectedValue: string if (!!param) { diff --git a/core/js/tests/unit/util/component-functions.spec.ts b/core/js/tests/unit/util/component-functions.spec.ts index 4656dd0cb..6cb8e39e4 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 } from '../../../src/bootstrap/util/component-functions' +import { enableDismissTrigger, eventActionOnPlugin } from '../../../src/bootstrap/util/component-functions' import { clearFixture, createEvent, getFixture } from '../../helpers/fixture' class DummyClass extends BaseComponent { @@ -85,4 +85,64 @@ describe('Component Functions', () => { vi.restoreAllMocks() }) }) + + describe('eventActionOnPlugin', () => { + it('should get plugin for the trigger element and execute given method on click', () => { + fixtureEl.innerHTML = '' + + const spyGet = vi.spyOn(DummyClass, 'getOrCreateInstance') + const spyTest = vi.spyOn(DummyClass.prototype, 'testMethod') + + eventActionOnPlugin(DummyClass, 'click', '[data-bs-toggle="test"]', 'testMethod') + fixtureEl.querySelector('[data-bs-toggle="test"]')!.dispatchEvent(createEvent('click')) + + expect(spyGet).toHaveBeenCalledWith(fixtureEl.querySelector('[data-bs-toggle="test"]')) + expect(spyTest).toHaveBeenCalled() + + vi.restoreAllMocks() + }) + + it('should resolve targets from data-bs-target and call the callback with instances', () => { + fixtureEl.innerHTML = ['', '
', '
'].join('') + + const spyTest = vi.spyOn(DummyClass.prototype, 'testMethod') + const callback = vi.fn() + + eventActionOnPlugin(DummyClass, 'click', '[data-bs-toggle="test2"]', 'testMethod', callback) + fixtureEl.querySelector('[data-bs-toggle="test2"]')!.dispatchEvent(createEvent('click')) + + expect(spyTest).toHaveBeenCalledTimes(2) + expect(callback).toHaveBeenCalledTimes(1) + expect(callback.mock.calls[0][0].instances).toHaveLength(2) + expect(callback.mock.calls[0][0].targets).toEqual([...fixtureEl.querySelectorAll('.target')]) + + vi.restoreAllMocks() + }) + + it('should not trigger if disabled', () => { + fixtureEl.innerHTML = '' + + const spy = vi.spyOn(DummyClass, 'getOrCreateInstance') + + eventActionOnPlugin(DummyClass, 'click', '[data-bs-toggle="test3"]', 'testMethod') + fixtureEl.querySelector('[data-bs-toggle="test3"]')!.dispatchEvent(createEvent('click')) + + expect(spy).not.toHaveBeenCalled() + + vi.restoreAllMocks() + }) + + it('should preventDefault for elements', () => { + fixtureEl.innerHTML = '' + + const preventSpy = vi.spyOn(Event.prototype, 'preventDefault') + + eventActionOnPlugin(DummyClass, 'click', '[data-bs-toggle="test4"]', 'testMethod') + fixtureEl.querySelector('[data-bs-toggle="test4"]')!.dispatchEvent(createEvent('click')) + + expect(preventSpy).toHaveBeenCalled() + + vi.restoreAllMocks() + }) + }) }) diff --git a/core/tsconfig.json b/core/tsconfig.json index 8bed5e6f8..468da5220 100644 --- a/core/tsconfig.json +++ b/core/tsconfig.json @@ -6,7 +6,7 @@ "moduleResolution": "bundler", "esModuleInterop": true, "allowSyntheticDefaultImports": true, - "strict": false, + "strict": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, @@ -17,14 +17,6 @@ "sourceMap": true, "allowJs": true }, - "include": [ - "js/**/*" - ], - "exclude": [ - "node_modules", - "dist", - "**/*.test.ts", - "**/*.spec.ts" - ] + "include": ["js/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"] } -