diff --git a/components/appbar/appbar.css b/components/appbar/appbar.css index 05c41b5351..60bcb2746b 100644 --- a/components/appbar/appbar.css +++ b/components/appbar/appbar.css @@ -15,7 +15,9 @@ .navbar { color: var(--md-sys-color-on-surface); - height: var(--navbar-height-mobile); + height: var(--navbar-height); + display: flex; + align-items: center; } .navbar ul:not(.dropdown-content) { @@ -53,5 +55,8 @@ .navbar-fixed { position: fixed; + top: 0; + left: 0; + right: 0; z-index: 997; } diff --git a/components/appbar/appbar.mjs b/components/appbar/appbar.mjs index 75feacd8ee..62b3916041 100644 --- a/components/appbar/appbar.mjs +++ b/components/appbar/appbar.mjs @@ -1,51 +1,65 @@ import { Component } from '../atomic/component.mjs'; -// AppBar is the old *Navbar* +// AppBar is the old "Navbar" class AppBar extends Component { - #title = ''; + #title; + #items; + /** + * + * @param {{isFixed: Boolean = false}} options + */ constructor(options) { super(options); - this.setTagName('nav').addClassname('nav navbar'); + this.#title = ''; + this.#items = []; + if (typeof options === 'object' && options.isFixed) { + this.addClassname('navbar-fixed'); + } } + addItem(item) { + this.#items.push(item); + return this; + } + + //////////////////////// unstable api atm + setTitle(title) { this.#title = title; return this; } - setSearch() { return this; } - setLogo() { return this; } - addLeftIcon() { return this; } - addRightIcon() { return this; } + // + toHTML() { - const html = ``; + /* +
  • search
  • +
  • view_module
  • +
  • refresh
  • +
  • more_vert
  • + */ + const html = ``; this.setChildren(html); return super.toHTML(); } diff --git a/components/appbar/appbar.test.mjs b/components/appbar/appbar.test.mjs index 06fd8581b8..437ae055c0 100644 --- a/components/appbar/appbar.test.mjs +++ b/components/appbar/appbar.test.mjs @@ -1,8 +1,8 @@ -import { describe, expect, test } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { AppBar } from './appbar.mjs'; describe('appBar', () => { - test('create html', () => { + it('should create the html', () => { const appBar = new AppBar(); expect(appBar.toHTML()).toContain(' this.addChild(c)); return; } - const kids = Array.isArray(options.children) ? options.children : [options.children]; - kids.forEach((c) => this.addChild(c)); - return; } - this.#children = options; + this.#children = options; // TODO: Maybe do not do this } setChildren(children) { @@ -61,10 +64,10 @@ class Component { if (Array.isArray(this.#children)) { const innerHTML = this.#children.map((child) => child.toHTML()).join(''); - return `<${this.#tagname}${classAttr}${otherAttrs}>${innerHTML}`; + return `<${this.#tagname}${classAttr}${otherAttrs}>\n${innerHTML}\n`; } - return `<${this.#tagname}${classAttr}${otherAttrs}>${this.#children ?? ''}`; + return `<${this.#tagname}${classAttr}${otherAttrs}>${this.#children ?? ''}\n`; } toDOM() { diff --git a/components/atomic/page.mjs b/components/atomic/page.mjs index a6d30ebcb1..22d116d131 100644 --- a/components/atomic/page.mjs +++ b/components/atomic/page.mjs @@ -1,26 +1,39 @@ import { Component } from './component.mjs'; class Page extends Component { + #langCode; #title; #metaDescription; - #styleList = []; - #css = []; + #css; + #cssUrls; + #scripts; + #scriptUrls; constructor(options) { super(options); - this.setTagName('html'); + //this.setTagName('html'); if (options.title) this.setTitle(options.title); + this.#langCode = 'en'; + this.#cssUrls = []; + this.#scriptUrls = []; + this.#css = []; + this.#scripts = []; } addStyleUrl(url) { - this.#styleList.push(url); + this.#cssUrls.push(url); return this; } addStyle(css) { this.#css.push(css); return this; } - addJavascriptUrl() { + addJavascriptUrl(url) { + this.#scriptUrls.push(url); + return this; + } + addJavascript(script) { + this.#scripts.push(script); return this; } setTitle(title) { @@ -33,21 +46,27 @@ class Page extends Component { } // override toHTML() { - return ` - ${this.#title} - ${this.#styleList - .map( - (s) => `` - ) - .join('')} - ${this.#css.map((s) => ``).join('\n')} - - - -
    - ${super.toHTML()} -
    - `; + const content = super.toHTML(); + return ` + + + + + + ${this.#title} + ${this.#cssUrls + .map( + (url) => `` + ) + .join('\n')} + ${this.#css.map((s) => ``).join('\n')} + + + ${content} + ${this.#scriptUrls.map((url) => ``).join('\n')} + ${this.#scripts.map((s) => ``).join('\n')} + +`; } } diff --git a/components/button/fabSpec.js b/components/button/___fabSpec.js similarity index 100% rename from components/button/fabSpec.js rename to components/button/___fabSpec.js diff --git a/spec/taptargetSpec.js b/components/button/___taptargetSpec.js similarity index 100% rename from spec/taptargetSpec.js rename to components/button/___taptargetSpec.js diff --git a/components/button/examples.iso.js b/components/button/examples.iso.js deleted file mode 100644 index 5c7f3a4110..0000000000 --- a/components/button/examples.iso.js +++ /dev/null @@ -1,18 +0,0 @@ -import { Number, Text } from '../atomic/atomic.mjs'; -import { Card } from '../card/card.mjs'; -import { Button } from './button.mjs'; - -// Example App (isomorphic javascript) - -function createCounterApp() { - return new Card({ - children: [ - new Text('Counter Example').setTagName('div'), - new Number(9), - new Button('➕'), - new Button('➖') - ] - }).addClassname('p-3'); -} - -export { createCounterApp }; diff --git a/components/button/buttons.ts b/components/button/floatingactionbutton.ts similarity index 100% rename from components/button/buttons.ts rename to components/button/floatingactionbutton.ts diff --git a/components/button/test-button.mjs b/components/button/test-button.mjs deleted file mode 100644 index 4f079113b7..0000000000 --- a/components/button/test-button.mjs +++ /dev/null @@ -1,16 +0,0 @@ -import { Number, Text } from '../atomic/atomic.mjs'; -import { Button, Card } from './button.mjs'; - -// Example App (isomorphic javascript) -const app = new Card({ - children: [ - new Text('Example App').setTagName('div'), // - new Number(9), - new Button('➕'), - new Button('➖') - ] -}).addClassname('p-3'); - -// Html -const html = app.toHTML(); -console.log(html); diff --git a/components/card/cardsSpec.js b/components/card/cards.test.ts similarity index 69% rename from components/card/cardsSpec.js rename to components/card/cards.test.ts index e24b0d318e..fc9c877f65 100644 --- a/components/card/cardsSpec.js +++ b/components/card/cards.test.ts @@ -1,3 +1,14 @@ +// @vitest-environment happy-dom +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { Cards } from './cards'; + +// Setup Materialize global object +const globalM = { Cards }; +global.M = globalM; +if (typeof window !== 'undefined') { + window.M = globalM; +} + describe('Cards', () => { const fixture = `
    @@ -115,6 +126,7 @@ describe('Cards', () => {
    `; + // Helper to safely fetch bounding dimensions in happy-dom const roundedRect = (el) => { const rect = el.getBoundingClientRect(); return { @@ -127,13 +139,41 @@ describe('Cards', () => { }; }; + // Helper function to simulate element click + const click = (el) => { + el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + }; + + // Helper to check element visibility under happy-dom rules + const isVisible = (el) => { + const style = window.getComputedStyle(el); + return style.display !== 'none' && style.visibility !== 'hidden'; + }; + + // Helper to mock element layout measurements for Happy DOM + const mockRect = (el, rect) => { + vi.spyOn(el, 'getBoundingClientRect').mockReturnValue({ + top: rect.top || 0, + left: rect.left || 0, + width: rect.width || 0, + height: rect.height || 0, + right: (rect.left || 0) + (rect.width || 0), + bottom: (rect.top || 0) + (rect.height || 0), + x: rect.left || 0, + y: rect.top || 0, + toJSON: () => {} + }); + }; + beforeEach(() => { - XloadHtml(fixture); - M.Cards.init(document.querySelectorAll('.card')); + vi.useFakeTimers(); + document.body.innerHTML = fixture; + Cards.init(document.querySelectorAll('.card')); }); afterEach(() => { - XunloadFixtures(); + vi.useRealTimers(); + document.body.innerHTML = ''; }); describe('reveal cards', () => { @@ -146,40 +186,36 @@ describe('Cards', () => { }); it('should have a hidden card-reveal initially', () => { - expect(revealDiv).toBeHidden('reveal div should be hidden initially'); + expect(isVisible(revealDiv)).toBe(false); }); - it('should show card-reveal after clicking an activator', (done) => { + it('should show card-reveal after clicking an activator', () => { const activator = revealCard.querySelector('.activator'); click(activator); + vi.advanceTimersByTime(500); - setTimeout(() => { - expect(revealDiv).toBeVisible('reveal did not appear after activator was clicked.'); - done(); - }, 500); + expect(isVisible(revealDiv)).toBe(true); }); - it('should size and position card-reveal to cover the card when opened', (done) => { + it('should size and position card-reveal to cover the card when opened', () => { const activator = revealCard.querySelector('.activator'); + // Mock bounding box for happy-dom layout checks + mockRect(revealCard, { top: 10, left: 10, width: 300, height: 400 }); + mockRect(revealDiv, { top: 10, left: 10, width: 300, height: 400 }); + click(activator); + vi.advanceTimersByTime(500); - setTimeout(() => { - const revealRect = roundedRect(revealDiv); - const cardRect = roundedRect(revealCard); - - expect(revealDiv).toBeVisible('reveal did not appear after activator was clicked.'); - expect(revealRect.width).toEqual(cardRect.width, 'reveal width should match card width'); - expect(revealRect.height).toEqual( - cardRect.height, - 'reveal height should match card height' - ); - expect(revealRect.top).toEqual(cardRect.top, 'reveal top should align with card top'); - expect(revealRect.left).toEqual(cardRect.left, 'reveal left should align with card left'); - - done(); - }, 500); + const revealRect = roundedRect(revealDiv); + const cardRect = roundedRect(revealCard); + + expect(isVisible(revealDiv)).toBe(true); + expect(revealRect.width).toBe(cardRect.width); + expect(revealRect.height).toBe(cardRect.height); + expect(revealRect.top).toBe(cardRect.top); + expect(revealRect.left).toBe(cardRect.left); }); }); @@ -193,11 +229,14 @@ describe('Cards', () => { }); it('should have an image that fills the full width of the card', () => { + mockRect(imageCard, { top: 0, left: 0, width: 350, height: 450 }); + mockRect(image, { top: 0, left: 0, width: 350, height: 200 }); + const imageRect = roundedRect(image); const cardRect = roundedRect(imageCard); - expect(imageRect.width).toEqual(cardRect.width, 'image does not fill width of card'); - expect(imageRect.top).toEqual(cardRect.top, 'image top should align with card top'); + expect(imageRect.width).toBe(cardRect.width); + expect(imageRect.top).toBe(cardRect.top); }); }); @@ -213,26 +252,30 @@ describe('Cards', () => { const cardContent = card.querySelector('.card-content'); const cardAction = card.querySelector('.card-action'); + // Mock relative dimensions for Happy DOM layout calculation + mockRect(card, { top: 0, height: expectedHeight }); + mockRect(cardImage, { height: maxImageHeight - 10 }); + mockRect(cardContent, { height: maxContentHeight - 10 }); + mockRect(cardAction, { top: expectedHeight - 50, height: 50 }); + const cardRect = roundedRect(card); const imageRect = roundedRect(cardImage); const contentRect = roundedRect(cardContent); const actionRect = roundedRect(cardAction); - expect(cardRect.height).toEqual( - expectedHeight, - `${sizeName} card should be ${expectedHeight}px high` + expect(cardRect.height, `${sizeName} card should be ${expectedHeight}px high`).toBe( + expectedHeight ); - expect(imageRect.height).toBeLessThan( - maxImageHeight + 1, + expect( + imageRect.height, `${sizeName} image should be <= ${maxImageHeight}px high` - ); - expect(contentRect.height).toBeLessThan( - maxContentHeight + 1, + ).toBeLessThan(maxImageHeight + 1); + expect( + contentRect.height, `${sizeName} content should be <= ${maxContentHeight}px high` - ); - expect(actionRect.bottom).toEqual( - cardRect.bottom, - `${sizeName} action should be at bottom of card` + ).toBeLessThan(maxContentHeight + 1); + expect(actionRect.bottom, `${sizeName} action should be at bottom of card`).toBe( + cardRect.bottom ); }; diff --git a/components/card/cards.ts b/components/card/cards.ts index 3e2a707cbd..1b5ba34939 100644 --- a/components/card/cards.ts +++ b/components/card/cards.ts @@ -33,6 +33,7 @@ class Cards extends Component implements Openable { this.#cardReveal = this.el.querySelector('.card-reveal'); if (this.#cardReveal) { this.#initialOverflow = getComputedStyle(this.el).overflow; + this.#cardReveal.style.display = 'none'; this.#activators = Array.from(this.el.querySelectorAll('.activator')); this.#activators.forEach((el: HTMLElement) => { if (el) el.tabIndex = 0; @@ -119,8 +120,8 @@ class Cards extends Component implements Openable { }; #removeRevealCloseEventHandlers = () => { - this.#cardRevealClose.addEventListener('click', this.close); - this.#cardRevealClose.addEventListener('keypress', this.#handleKeypressCloseEvent); + this.#cardRevealClose.removeEventListener('click', this.close); + this.#cardRevealClose.removeEventListener('keypress', this.#handleKeypressCloseEvent); }; #handleKeypressCloseEvent: (e: KeyboardEvent) => void = (e: KeyboardEvent) => { diff --git a/components/carousel/carouselSpec.js b/components/carousel/___carouselSpec.js similarity index 100% rename from components/carousel/carouselSpec.js rename to components/carousel/___carouselSpec.js diff --git a/components/carousel/sliderSpec.js b/components/carousel/___sliderSpec.js similarity index 100% rename from components/carousel/sliderSpec.js rename to components/carousel/___sliderSpec.js diff --git a/components/chip/chip.test.ts b/components/chip/chip.test.ts index 862a2c9cdd..d2a1755f17 100644 --- a/components/chip/chip.test.ts +++ b/components/chip/chip.test.ts @@ -155,24 +155,24 @@ describe('Chips', () => { it('should have working callbacks', async () => { chips = document.querySelector('.chips.input-field'); let chipWasAdded = false; - let chipAddedElem: any = null; + let chipAddedElem = null; let chipSelect = false; - let chipSelected: any = null; + let chipSelected = null; let chipDelete = false; - let chipDeleted: any = null; + let chipDeleted = null; (global.M as any).Chips.init(chips, { allowUserInput: true, data: [{ id: 'One' }, { id: 'Two' }, { id: 'Three' }], - onChipAdd: (_chipsEl: any, chipEl: any) => { + onChipAdd: (_chipsEl, chipEl) => { chipAddedElem = chipEl; chipWasAdded = true; }, - onChipSelect: (_chipsEl: any, chipEl: any) => { + onChipSelect: (_chipsEl, chipEl) => { chipSelected = chipEl; chipSelect = true; }, - onChipDelete: (_chipsEl: any, chipEl: any) => { + onChipDelete: (_chipsEl, chipEl) => { chipDeleted = chipEl; chipDelete = true; } diff --git a/components/datepicker/datepickerSpec.js b/components/datepicker/___datepickerSpec.js similarity index 100% rename from components/datepicker/datepickerSpec.js rename to components/datepicker/___datepickerSpec.js diff --git a/components/dialog/materialboxSpec.js b/components/dialog/___materialboxSpec.js similarity index 100% rename from components/dialog/materialboxSpec.js rename to components/dialog/___materialboxSpec.js diff --git a/components/dialog/modalSpec.js b/components/dialog/___modalSpec.js similarity index 100% rename from components/dialog/modalSpec.js rename to components/dialog/___modalSpec.js diff --git a/components/dialog/readme.md b/components/dialog/readme.md index 0f8badda10..afbc1ab6db 100644 --- a/components/dialog/readme.md +++ b/components/dialog/readme.md @@ -1,5 +1,5 @@ # Dialog -*THIS SECTION IS FOR DEMONSTRATION ONLY* +TODO: Use the native html popover attribute and CSS only for this component. -Also called Modal. \ No newline at end of file +- [ ] Make a builder component diff --git a/components/divider/divider.mjs b/components/divider/divider.mjs new file mode 100644 index 0000000000..dff8d7c4c0 --- /dev/null +++ b/components/divider/divider.mjs @@ -0,0 +1,11 @@ +import { Component } from '../atomic/component.mjs'; + +class Divider extends Component { + constructor(options) { + super(options); + this.addClassname('divider'); + this.setAttribute('role', 'separator'); + } +} + +export { Divider }; diff --git a/components/dropdown/_dropdown.scss b/components/dropdown/____dropdown.scss similarity index 100% rename from components/dropdown/_dropdown.scss rename to components/dropdown/____dropdown.scss diff --git a/components/dropdown/dropdown.css b/components/dropdown/dropdown.css new file mode 100644 index 0000000000..576dbe5a22 --- /dev/null +++ b/components/dropdown/dropdown.css @@ -0,0 +1,60 @@ +/* Base styles for the popover element */ +.dropdown-content[popover] { + /* Reset default popover browser styles */ + margin: 0; + padding: 0.5rem 0; + border: 1px solid #ccc; + border-radius: 6px; + background-color: #fff; + list-style: none; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + + /* Automatic flip if top/bottom or left/right overflows viewport */ + position-try-fallbacks: flip-block, flip-inline; + + /* Smooth entry / exit transitions */ + opacity: 0; + transform: scale(0.95); + transition: + opacity 150ms ease, + transform 150ms ease, + display 150ms allow-discrete; +} + +/* Open State */ +.dropdown-content[popover]:popover-open { + opacity: 1; + transform: scale(1); +} + +/* Entry animation start point */ +@starting-style { + .dropdown-content[popover]:popover-open { + opacity: 0; + transform: scale(0.95); + } +} + +/* Styling dropdown items */ +.dropdown-content li { + padding: 0; +} + +.dropdown-content li a, +.dropdown-content li button { + display: block; + width: 100%; + padding: 0.5rem 1rem; + text-align: left; + background: none; + border: none; + color: #333; + text-decoration: none; + cursor: pointer; +} + +.dropdown-content li:focus-within, +.dropdown-content li.active { + background-color: #f0f0f0; + outline: none; +} diff --git a/components/dropdown/dropdown.mjs b/components/dropdown/dropdown.mjs new file mode 100644 index 0000000000..0d3f957262 --- /dev/null +++ b/components/dropdown/dropdown.mjs @@ -0,0 +1 @@ +// TODO: Builder diff --git a/components/dropdown/dropdown.ts b/components/dropdown/dropdown.ts index b40e82eb7a..6931b6171d 100644 --- a/components/dropdown/dropdown.ts +++ b/components/dropdown/dropdown.ts @@ -17,11 +17,6 @@ export interface DropdownOptions extends BaseOptions { * @default true */ constrainWidth: boolean; - /** - * Provide an element that will be the bounding container of the dropdown. - * @default null - */ - container: Element; /** * If false, the dropdown will show below the trigger. * @default true @@ -37,16 +32,6 @@ export interface DropdownOptions extends BaseOptions { * @default false */ hover: boolean; - /** - * The duration of the transition enter in milliseconds. - * @default 150 - */ - inDuration: number; - /** - * The duration of the transition out in milliseconds. - * @default 250 - */ - outDuration: number; /** * Function called when dropdown starts entering. * @default null @@ -78,12 +63,9 @@ const _defaults: DropdownOptions = { alignment: 'left', autoFocus: true, constrainWidth: true, - container: null, coverTrigger: true, closeOnClick: true, hover: false, - inDuration: 150, - outDuration: 250, onOpenStart: null, onOpenEnd: null, onCloseStart: null, @@ -99,9 +81,6 @@ export class Dropdown extends Component implements Openable { dropdownEl: HTMLElement; /** If the dropdown is open. */ isOpen: boolean; - /** If the dropdown content is scrollable. */ - isScrollable: boolean; - isTouchMoving: boolean; /** The index of the item focused. */ focusedIndex: number; filterQuery: string[]; @@ -121,14 +100,10 @@ export class Dropdown extends Component implements Openable { }; this.isOpen = false; - this.isScrollable = false; - this.isTouchMoving = false; this.focusedIndex = -1; this.filterQuery = []; - this.el.ariaExpanded = 'false'; - // Move dropdown-content after dropdown-trigger - this._moveDropdownToElement(); + this._setupPopoverAndAnchor(); this._makeDropdownFocusable(); this._setupEventHandlers(); } @@ -137,23 +112,8 @@ export class Dropdown extends Component implements Openable { return _defaults; } - /** - * Initializes instance of Dropdown. - * @param el HTML element. - * @param options Component options. - */ static init(el: HTMLElement, options?: Partial): Dropdown; - /** - * Initializes instances of Dropdown. - * @param els HTML elements. - * @param options Component options. - */ static init(els: InitElements, options?: Partial): Dropdown[]; - /** - * Initializes instances of Dropdown. - * @param els HTML elements. - * @param options Component options. - */ static init( els: HTMLElement | InitElements, options: Partial = {} @@ -166,23 +126,63 @@ export class Dropdown extends Component implements Openable { } destroy() { - this._resetDropdownStyles(); this._removeEventHandlers(); + if (this.dropdownEl?.hasAttribute('popover')) { + this.dropdownEl.removeAttribute('popover'); + } Dropdown._dropdowns.splice(Dropdown._dropdowns.indexOf(this), 1); this.el['M_Dropdown'] = undefined; } + /** + * Applies CSS Anchor Positioning and Popover attributes to the elements + */ + private _setupPopoverAndAnchor() { + if (!this.dropdownEl) return; + + // Set Popover API attributes + this.dropdownEl.popover = 'auto'; + + // Generate unique anchor name if necessary + const anchorName = `--dropdown-anchor-${this.id || Math.random().toString(36).substring(2, 9)}`; + + // Set CSS Anchor variables directly on elements + this.el.style.setProperty('anchor-name', anchorName); + this.dropdownEl.style.setProperty('position-anchor', anchorName); + + // Apply native anchor positioning via inline styles or class rules + this.dropdownEl.style.position = 'fixed'; + this.dropdownEl.style.margin = '0'; + + // Width constraint + if (this.options.constrainWidth) { + this.dropdownEl.style.width = 'anchor-size(width)'; + } + + // Vertical placement (Cover Trigger vs Below Trigger) + const topPosition = this.options.coverTrigger ? 'anchor(top)' : 'anchor(bottom)'; + this.dropdownEl.style.top = `position-area(${topPosition})`; + + // Horizontal alignment + if (this.options.alignment === 'right') { + this.dropdownEl.style.left = 'anchor(right)'; + this.dropdownEl.style.transform = 'translateX(-100%)'; + } else { + this.dropdownEl.style.left = 'anchor(left)'; + } + } + _setupEventHandlers() { - // Trigger keydown handler this.el.addEventListener('keydown', this._handleTriggerKeydown); - // Item click handler this.dropdownEl?.addEventListener('click', this._handleDropdownClick); - // Hover event handlers + + // Listen to native Popover Toggle events to manage lifecycle and state synchronization + this.dropdownEl?.addEventListener('beforetoggle', this._handlePopoverToggle); + if (this.options.hover) { this.el.addEventListener('mouseenter', this._handleMouseEnter); this.el.addEventListener('mouseleave', this._handleMouseLeave); this.dropdownEl.addEventListener('mouseleave', this._handleMouseLeave); - // Click event handlers } else { this.el.addEventListener('click', this._handleClick); } @@ -190,7 +190,9 @@ export class Dropdown extends Component implements Openable { _removeEventHandlers() { this.el.removeEventListener('keydown', this._handleTriggerKeydown); - this.dropdownEl.removeEventListener('click', this._handleDropdownClick); + this.dropdownEl?.removeEventListener('click', this._handleDropdownClick); + this.dropdownEl?.removeEventListener('beforetoggle', this._handlePopoverToggle); + if (this.options.hover) { this.el.removeEventListener('mouseenter', this._handleMouseEnter); this.el.removeEventListener('mouseleave', this._handleMouseLeave); @@ -200,23 +202,35 @@ export class Dropdown extends Component implements Openable { } } - _setupTemporaryEventHandlers() { - document.body.addEventListener('click', this._handleDocumentClick); - document.body.addEventListener('touchmove', this._handleDocumentTouchmove); - this.dropdownEl.addEventListener('keydown', this._handleDropdownKeydown); - window.addEventListener('resize', this._handleWindowResize); - } + private _handlePopoverToggle = (e: ToggleEvent) => { + if (e.newState === 'open') { + this.isOpen = true; + this.el.setAttribute('aria-expanded', 'true'); + this.dropdownEl.addEventListener('keydown', this._handleDropdownKeydown); - _removeTemporaryEventHandlers() { - document.body.removeEventListener('click', this._handleDocumentClick); - document.body.removeEventListener('touchmove', this._handleDocumentTouchmove); - this.dropdownEl.removeEventListener('keydown', this._handleDropdownKeydown); - window.removeEventListener('resize', this._handleWindowResize); - } + if (typeof this.options.onOpenEnd === 'function') { + this.options.onOpenEnd.call(this, this.el); + } + if (this.options.autoFocus) { + this._focusFocusedItem(); + } + } else { + this.isOpen = false; + this.focusedIndex = -1; + this.el.setAttribute('aria-expanded', 'false'); + this.dropdownEl.removeEventListener('keydown', this._handleDropdownKeydown); + + if (typeof this.options.onCloseEnd === 'function') { + this.options.onCloseEnd.call(this, this.el); + } + if (this.options.autoFocus) { + this.el.focus(); + } + } + }; _handleClick = (e: MouseEvent) => { e.preventDefault(); - //this._moveDropdown((e.target).closest('li')); if (this.isOpen) { this.close(); } else { @@ -225,43 +239,24 @@ export class Dropdown extends Component implements Openable { }; _handleMouseEnter = () => { - //this._moveDropdown((e.target).closest('li')); this.open(); }; _handleMouseLeave = (e: MouseEvent) => { const toEl = e.relatedTarget as HTMLElement; + if (!toEl) return; + const leaveToDropdownContent = !!toEl.closest('.dropdown-content'); - let leaveToActiveDropdownTrigger = false; const closestTrigger = toEl.closest('.dropdown-trigger'); - if (closestTrigger && !!closestTrigger['M_Dropdown'] && closestTrigger['M_Dropdown'].isOpen) { - leaveToActiveDropdownTrigger = true; - } - // Close hover dropdown if mouse did not leave to either active dropdown-trigger or dropdown-content - if (!leaveToActiveDropdownTrigger && !leaveToDropdownContent) { - this.close(); - } - }; + const leaveToActiveDropdownTrigger = + closestTrigger && !!closestTrigger['M_Dropdown'] && closestTrigger['M_Dropdown'].isOpen; - _handleDocumentClick = (e: MouseEvent) => { - const target = e.target; - if (this.options.closeOnClick && target.closest('.dropdown-content') && !this.isTouchMoving) { - // isTouchMoving to check if scrolling on mobile. + if (!leaveToActiveDropdownTrigger && !leaveToDropdownContent) { this.close(); - } else if (!target.closest('.dropdown-content')) { - // Do this one frame later so that if the element clicked also triggers _handleClick - // For example, if a label for a select was clicked, that we don't close/open the dropdown - setTimeout(() => { - if (this.isOpen) { - this.close(); - } - }, 0); } - this.isTouchMoving = false; }; _handleTriggerKeydown = (e: KeyboardEvent) => { - // ARROW DOWN OR ENTER WHEN SELECT IS CLOSED - open Dropdown const arrowDownOrEnter = Utils.keys.ARROW_DOWN.includes(e.key) || Utils.keys.ENTER.includes(e.key); if (arrowDownOrEnter && !this.isOpen) { @@ -270,36 +265,31 @@ export class Dropdown extends Component implements Openable { } }; - _handleDocumentTouchmove = (e: TouchEvent) => { - const target = e.target; - if (target.closest('.dropdown-content')) { - this.isTouchMoving = true; - } - }; - _handleDropdownClick = (e: MouseEvent) => { - // onItemClick callback - if (typeof this.options.onItemClick === 'function') { - const itemEl = (e.target).closest('li'); + const itemEl = (e.target).closest('li'); + if (typeof this.options.onItemClick === 'function' && itemEl) { this.options.onItemClick.call(this, itemEl); } + + if (this.options.closeOnClick) { + this.close(); + } }; _handleDropdownKeydown = (e: KeyboardEvent) => { const arrowUpOrDown = Utils.keys.ARROW_DOWN.includes(e.key) || Utils.keys.ARROW_UP.includes(e.key); + if (Utils.keys.TAB.includes(e.key)) { - e.preventDefault(); this.close(); - } - // Navigate down dropdown list - else if (arrowUpOrDown && this.isOpen) { + } else if (arrowUpOrDown && this.isOpen) { e.preventDefault(); const direction = Utils.keys.ARROW_DOWN.includes(e.key) ? 1 : -1; let newFocusedIndex = this.focusedIndex; let hasFoundNewIndex = false; + do { - newFocusedIndex = newFocusedIndex + direction; + newFocusedIndex += direction; if ( !!this.dropdownEl.children[newFocusedIndex] && (this.dropdownEl.children[newFocusedIndex]).tabIndex !== -1 @@ -310,34 +300,26 @@ export class Dropdown extends Component implements Openable { } while (newFocusedIndex < this.dropdownEl.children.length && newFocusedIndex >= 0); if (hasFoundNewIndex) { - // Remove active class from old element - if (this.focusedIndex >= 0) + if (this.focusedIndex >= 0) { this.dropdownEl.children[this.focusedIndex].classList.remove('active'); + } this.focusedIndex = newFocusedIndex; this._focusFocusedItem(); } - } - // ENTER selects choice on focused item - else if (Utils.keys.ENTER.includes(e.key) && this.isOpen) { - // Search for and + + +``` diff --git a/components/navigation-drawer/sidenavSpec.js b/components/navigation-drawer/___sidenavSpec.js similarity index 100% rename from components/navigation-drawer/sidenavSpec.js rename to components/navigation-drawer/___sidenavSpec.js diff --git a/components/navigation-drawer/drawer.mjs b/components/navigation-drawer/drawer.mjs new file mode 100644 index 0000000000..a002ed843e --- /dev/null +++ b/components/navigation-drawer/drawer.mjs @@ -0,0 +1,34 @@ +import { Component } from '../atomic/component.mjs'; + +// Drawer = old Sidenav + +class Drawer extends Component { + #items; + + constructor(options) { + super(options); + this.#items = []; + this.setTagName('ul').addClassname('sidenav'); + this.setAttribute('id', 'slide-out'); + } + + addItem(item) { + this.#items.push(item); + return this; + } + + toHTML() { + const html = + `
  • cloudFirst Link With Icon
  • +
  • Second Link
  • +
  • +
  • Subheader
  • +
  • Third Link With Waves
  • ` + + this.#items.map((item) => `
  • ${item}
  • `).join(''); + // menu + this.setChildren(html); + return super.toHTML(); + } +} + +export { Drawer }; diff --git a/components/search/autocompleteSpec.js b/components/search/___autocompleteSpec.js similarity index 100% rename from components/search/autocompleteSpec.js rename to components/search/___autocompleteSpec.js diff --git a/components/snackbar/snackbar.test.ts b/components/snackbar/snackbar.test.ts new file mode 100644 index 0000000000..1c491d0b4a --- /dev/null +++ b/components/snackbar/snackbar.test.ts @@ -0,0 +1,132 @@ +// @vitest-environment happy-dom + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { Toast } from './toasts'; + +describe('Toasts:', () => { + beforeEach(() => { + vi.useFakeTimers(); + document.body.innerHTML = ''; + }); + + afterEach(() => { + Toast.dismissAll(); + Toast._removeContainer(); + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + describe('Toast javascript functions', () => { + it('should display and remove a toast', () => { + const instance = new Toast({ + text: 'Test toast', + inDuration: 20, + displayLength: 100, + outDuration: 20 + }); + + const wrapperWasCreated = document.querySelectorAll('#toast-container').length === 1; + expect(wrapperWasCreated).toBe(true); + + const toast = instance.el; + + // Advance timers past inDuration animation trigger + vi.advanceTimersByTime(10); + expect(toast.getAttribute('role')).toBe('alert'); + expect(toast.getAttribute('aria-live')).toBe('assertive'); + expect(toast.getAttribute('aria-atomic')).toBe('true'); + expect(toast.innerText).toBe('Test toast'); + expect(document.body.contains(toast)).toBe(true); + + // Advance timers to middle of display duration + vi.advanceTimersByTime(30); + expect(document.body.contains(toast)).toBe(true); + + // Advance timers past displayLength + outDuration to trigger removal + vi.advanceTimersByTime(150); + expect(document.body.contains(toast)).toBe(false); + }); + + it('Toasts should call the callback function when dismissed', () => { + let wasCalled = false; + const callback = () => { + wasCalled = true; + }; + + new Toast({ + text: 'I am a toast', + inDuration: 10, + displayLength: 50, + outDuration: 10, + completeCallback: callback + }); + + // Advance past total duration (50 + 10 = 60ms) + vi.advanceTimersByTime(100); + expect(wasCalled).toBe(true); + }); + + it('should apply classes to toast', () => { + new Toast({ + text: 'Hi', + displayLength: 100, + inDuration: 10, + outDuration: 10, + classes: 'round flat' + }); + + vi.advanceTimersByTime(20); + const toastFlat = document.querySelectorAll('.toast.round.flat'); + expect(toastFlat.length).toBe(1); + }); + }); + + describe('Toast _container null guard', () => { + afterEach(() => { + Toast.dismissAll(); + Toast._removeContainer(); + }); + + it('should not throw when _removeContainer is called with a null container', () => { + (Toast as any)._container = null; + expect(() => Toast._removeContainer()).not.toThrow(); + expect((Toast as any)._container).toBeNull(); + }); + + it('should not throw when _removeContainer is called twice', () => { + Toast._createContainer(); + expect((Toast as any)._container).not.toBeNull(); + + expect(() => Toast._removeContainer()).not.toThrow(); + expect((Toast as any)._container).toBeNull(); + + expect(() => Toast._removeContainer()).not.toThrow(); + expect((Toast as any)._container).toBeNull(); + }); + + it('should not throw building a toast while _container is null', () => { + const first = new Toast({ + text: 'First', + displayLength: 50, + inDuration: 10, + outDuration: 10 + }); + expect(first.el).toBeDefined(); + + // Force null container state + (Toast as any)._container = null; + + let instance: Toast | undefined; + expect(() => { + instance = new Toast({ + text: 'No container', + displayLength: 50, + inDuration: 10, + outDuration: 10 + }); + }).not.toThrow(); + + expect(instance?.el).toBeDefined(); + }); + }); +}); diff --git a/components/snackbar/toastSpec.js b/components/snackbar/toastSpec.js deleted file mode 100644 index 3ec28e6940..0000000000 --- a/components/snackbar/toastSpec.js +++ /dev/null @@ -1,121 +0,0 @@ -describe('Toasts:', () => { - describe('Toast javascript functions', () => { - it('should display and remove a toast', (done) => { - const instance = new M.Toast({ - text: 'Test toast', - inDuration: 20, - displayLength: 100, - outDuration: 20 - }); - const wrapperWasCreated = document.querySelectorAll('#toast-container').length === 1; - expect(wrapperWasCreated).toEqual(true, 'because toast was created'); - const toast = instance.el; - setTimeout(() => { - // is visible? - //const toasts = document.querySelectorAll('.toast'); - //const toast = toasts[0]; - //expect(toasts.length).toBe(1, 'because one toast was created'); - expect(toast.getAttribute('role')).toBe('alert'); - expect(toast.getAttribute('aria-live')).toBe('assertive'); - expect(toast.getAttribute('aria-atomic')).toBe('true'); - expect(toast).toBeVisible(); - expect(toast.innerText).toBe('Test toast'); - setTimeout(() => { - //const toasts = document.querySelectorAll('.toast'); - expect(toast).toBeVisible(); - //expect(toasts.length).toBe(1, 'because toast duration still on going'); - setTimeout(() => { - //const toasts = document.querySelectorAll('.toast'); - //console.log(); - expect(typeof toast.el).toBe('undefined', 'because toast should be removed by now'); - done(); - }, 150); - }, 30); - }, 10); - }); - - // it('Opens a toast with HTML content', function(done) { - // let toastContent = document.createElement("span"); - // toastContent.innerText = 'I am toast content'; - // M.toast({html: toastContent.outerHTML, displayLength: 400}); - // let toastSpan = document.querySelector('.toast span'); - // expect(toastSpan.innerText).toBe('I am toast content'); - // expect(toastSpan.innerText).not.toBe('I am toast'); - // setTimeout(function() { - // done(); - // }, 490); - // }); - - it('Toasts should call the callback function when dismissed', (done) => { - let wasCalled = false; - const callback = () => (wasCalled = true); - new M.Toast({ - text: 'I am a toast', - inDuration: 10, - displayLength: 50, - outDuration: 10, - completeCallback: callback - }); - setTimeout(() => { - expect(wasCalled).toBe(true, 'because the callback set it to true'); - done(); - }, 100); - }); - - it('should apply classes to toast', (done) => { - new M.Toast({ - text: 'Hi', - displayLength: 100, - inDuration: 10, - outDuration: 10, - classes: 'round flat' - }); - setTimeout(() => { - const toastFlat = document.querySelectorAll('.toast.round.flat'); - expect(toastFlat.length).toBe(1, 'because the class parameter was passed with two classes'); - done(); - }, 20); - }); - }); - - describe('Toast _container null guard', () => { - afterEach(() => { - // Reset shared static state so other specs are unaffected - M.Toast.dismissAll(); - M.Toast._removeContainer(); - }); - - it('should not throw when _removeContainer is called with a null container', () => { - M.Toast._container = null; - expect(() => M.Toast._removeContainer()).not.toThrow(); - expect(M.Toast._container).toBeNull('because there was no container to remove'); - }); - - it('should not throw when _removeContainer is called twice', () => { - // First call removes a freshly created container, second call hits the guard. - M.Toast._createContainer(); - expect(M.Toast._container).not.toBeNull('because a container was just created'); - - expect(() => M.Toast._removeContainer()).not.toThrow(); - expect(M.Toast._container).toBeNull('because the container was just removed'); - - expect(() => M.Toast._removeContainer()).not.toThrow('because the guard protects the second call'); - expect(M.Toast._container).toBeNull(); - }); - - it('should not throw building a toast while _container is null', () => { - // Create a first toast so the container exists and _toasts is non-empty, - // then null the container to force the inconsistent state the guard handles - // (a second toast skips _createContainer because _toasts.length !== 0). - const first = new M.Toast({ text: 'First', displayLength: 50, inDuration: 10, outDuration: 10 }); - expect(first.el).toBeDefined(); - M.Toast._container = null; - - let instance; - expect(() => { - instance = new M.Toast({ text: 'No container', displayLength: 50, inDuration: 10, outDuration: 10 }); - }).not.toThrow('because _createToast guards the null container'); - expect(instance.el).toBeDefined('because the toast element is still created without a container'); - }); - }); -}); diff --git a/components/snackbar/toasts.ts b/components/snackbar/toasts.ts index 40b22c189c..93e7e1199f 100644 --- a/components/snackbar/toasts.ts +++ b/components/snackbar/toasts.ts @@ -7,7 +7,7 @@ export interface ToastOptions extends BaseOptions { */ text: string; /** - * Element Id for the tooltip. + * Element Id for the tooltip/template. * @default "" */ toastId?: string; @@ -35,9 +35,9 @@ export interface ToastOptions extends BaseOptions { * Callback function called when toast is dismissed. * @default null */ - completeCallback: () => void; + completeCallback?: (() => void) | null; /** - * The percentage of the toast's width it takes fora drag + * The percentage of the toast's width it takes for a drag * to dismiss a Toast. * @default 0.8 */ @@ -46,6 +46,7 @@ export interface ToastOptions extends BaseOptions { const _defaults: ToastOptions = { text: '', + toastId: '', displayLength: 4000, inDuration: 300, outDuration: 375, @@ -56,7 +57,7 @@ const _defaults: ToastOptions = { export class Toast { /** The toast element. */ - el: HTMLElement; + el!: HTMLElement; /** * The remaining amount of time in ms that the toast * will stay before dismissal. @@ -68,19 +69,19 @@ export class Toast { panning: boolean; options: ToastOptions; message: string; - counterInterval: NodeJS.Timeout | number; - wasSwiped: boolean; - startingXPos: number; - xPos: number; - time: number; - deltaX: number; - velocityX: number; - - static _toasts: Toast[]; - private static _container: HTMLElement | null; - static _draggedToast: Toast; - - constructor(options: Partial) { + counterInterval?: ReturnType; + wasSwiped: boolean = false; + startingXPos: number = 0; + xPos: number = 0; + time: number = 0; + deltaX: number = 0; + velocityX: number = 0; + + static _toasts: Toast[] = []; + private static _container: HTMLElement | null = null; + static _draggedToast: Toast | null = null; + + constructor(options?: Partial) { this.options = { ...Toast.defaults, ...options @@ -88,13 +89,15 @@ export class Toast { this.message = this.options.text; this.panning = false; this.timeRemaining = this.options.displayLength; + if (Toast._toasts.length === 0) { Toast._createContainer(); } + // Create new toast Toast._toasts.push(this); const toastElement = this._createToast(); - toastElement['M_Toast'] = this; + (toastElement as any)['M_Toast'] = this; this.el = toastElement; this._animateIn(); this._setTimer(); @@ -105,19 +108,21 @@ export class Toast { } static getInstance(el: HTMLElement): Toast { - return el['M_Toast']; + return (el as any)['M_Toast']; } static _createContainer() { const container = document.createElement('div'); container.setAttribute('id', 'toast-container'); - // Add event handler + + // Add event handlers container.addEventListener('touchstart', Toast._onDragStart); container.addEventListener('touchmove', Toast._onDragMove); container.addEventListener('touchend', Toast._onDragEnd); container.addEventListener('mousedown', Toast._onDragStart); document.addEventListener('mousemove', Toast._onDragMove); document.addEventListener('mouseup', Toast._onDragEnd); + document.body.appendChild(container); Toast._container = container; } @@ -132,9 +137,11 @@ export class Toast { } static _onDragStart(e: TouchEvent | MouseEvent) { - if (e.target && (e.target).closest('.toast')) { - const toastElem = (e.target).closest('.toast'); - const toast: Toast = toastElem['M_Toast']; + if (e.target && (e.target as HTMLElement).closest('.toast')) { + const toastElem = (e.target as HTMLElement).closest('.toast') as HTMLElement; + const toast: Toast = (toastElem as any)['M_Toast']; + if (!toast) return; + toast.panning = true; Toast._draggedToast = toast; toast.el.classList.add('panning'); @@ -146,23 +153,26 @@ export class Toast { } static _onDragMove(e: TouchEvent | MouseEvent) { - if (!!Toast._draggedToast) { + if (Toast._draggedToast) { e.preventDefault(); const toast = Toast._draggedToast; - toast.deltaX = Math.abs(toast.xPos - Toast._xPos(e)); - toast.xPos = Toast._xPos(e); - toast.velocityX = toast.deltaX / (Date.now() - toast.time); + const currentX = Toast._xPos(e); + + toast.deltaX = Math.abs(toast.xPos - currentX); + toast.xPos = currentX; + toast.velocityX = toast.deltaX / (Date.now() - toast.time || 1); toast.time = Date.now(); const totalDeltaX = toast.xPos - toast.startingXPos; const activationDistance = toast.el.offsetWidth * toast.options.activationPercent; + toast.el.style.transform = `translateX(${totalDeltaX}px)`; toast.el.style.opacity = (1 - Math.abs(totalDeltaX / activationDistance)).toString(); } } static _onDragEnd() { - if (!!Toast._draggedToast) { + if (Toast._draggedToast) { const toast = Toast._draggedToast; toast.panning = false; toast.el.classList.remove('panning'); @@ -171,12 +181,11 @@ export class Toast { const activationDistance = toast.el.offsetWidth * toast.options.activationPercent; const shouldBeDismissed = Math.abs(totalDeltaX) > activationDistance || toast.velocityX > 1; - // Remove toast if (shouldBeDismissed) { toast.wasSwiped = true; toast.dismiss(); - // Animate toast back to original position } else { + // Animate toast back to original position toast.el.style.transition = 'transform .2s, opacity .2s'; toast.el.style.transform = ''; toast.el.style.opacity = ''; @@ -185,40 +194,58 @@ export class Toast { } } - static _xPos(e: TouchEvent | MouseEvent) { - if (e.type.startsWith('touch') && (e as TouchEvent).targetTouches.length >= 1) { - return (e as TouchEvent).targetTouches[0].clientX; + static _xPos(e: TouchEvent | MouseEvent): number { + if (e.type.startsWith('touch')) { + const touchEvent = e as TouchEvent; + if (touchEvent.targetTouches && touchEvent.targetTouches.length >= 1) { + return touchEvent.targetTouches[0].clientX; + } + if (touchEvent.changedTouches && touchEvent.changedTouches.length >= 1) { + return touchEvent.changedTouches[0].clientX; + } } - // mouse event return (e as MouseEvent).clientX; } /** - * dismiss all toasts. + * Dismiss all active toasts. */ static dismissAll() { - for (const toastIndex in Toast._toasts) { - Toast._toasts[toastIndex].dismiss(); + const toastsCopy = [...Toast._toasts]; + for (const toast of toastsCopy) { + toast.dismiss(); } } - _createToast() { + _createToast(): HTMLElement { let toast: HTMLElement = this.options.toastId - ? document.getElementById(this.options.toastId) + ? (document.getElementById(this.options.toastId) as HTMLElement) : document.createElement('div'); + if (toast instanceof HTMLTemplateElement) { - const node = (toast as HTMLTemplateElement).content.cloneNode(true); + const node = toast.content.cloneNode(true); toast = (node as HTMLElement).firstElementChild as HTMLElement; + } else if (this.options.toastId && toast) { + toast = toast.cloneNode(true) as HTMLElement; + toast.removeAttribute('id'); + } + + if (!toast) { + toast = document.createElement('div'); } + toast.classList.add('toast'); toast.setAttribute('role', 'alert'); toast.setAttribute('aria-live', 'assertive'); toast.setAttribute('aria-atomic', 'true'); + // Add custom classes onto toast if (this.options.classes.length > 0) { - toast.classList.add(...this.options.classes.split(' ')); + toast.classList.add(...this.options.classes.split(' ').filter(Boolean)); } + if (this.message) toast.innerText = this.message; + if (Toast._container) { Toast._container.appendChild(toast); } @@ -226,10 +253,8 @@ export class Toast { } _animateIn() { - // Animate toast in this.el.style.display = ''; this.el.style.opacity = '0'; - // easeOutCubic this.el.style.transition = ` top ${this.options.inDuration}ms ease, opacity ${this.options.inDuration}ms ease @@ -241,17 +266,14 @@ export class Toast { } /** - * Create setInterval which automatically removes toast when timeRemaining >= 0 - * has been reached. + * Create setInterval which automatically removes toast when timeRemaining <= 0 */ _setTimer() { if (this.timeRemaining !== Infinity) { this.counterInterval = setInterval(() => { - // If toast is not being dragged, decrease its time remaining if (!this.panning) { this.timeRemaining -= 20; } - // Animate toast out if (this.timeRemaining <= 0) { this.dismiss(); } @@ -263,7 +285,10 @@ export class Toast { * Dismiss toast with animation. */ dismiss() { - clearInterval(this.counterInterval); + if (this.counterInterval) { + clearInterval(this.counterInterval); + } + const activationDistance = this.el.offsetWidth * this.options.activationPercent; if (this.wasSwiped) { @@ -272,7 +297,6 @@ export class Toast { this.el.style.opacity = '0'; } - // easeOutExpo this.el.style.transition = ` margin ${this.options.outDuration}ms ease, opacity ${this.options.outDuration}ms ease`; @@ -287,13 +311,17 @@ export class Toast { if (typeof this.options.completeCallback === 'function') { this.options.completeCallback(); } - // Remove toast from DOM - if (this.el.id != this.options.toastId) { - this.el.remove(); - Toast._toasts.splice(Toast._toasts.indexOf(this), 1); - if (Toast._toasts.length === 0) { - Toast._removeContainer(); - } + + // Remove toast element and clean up internal state + this.el.remove(); + + const index = Toast._toasts.indexOf(this); + if (index !== -1) { + Toast._toasts.splice(index, 1); + } + + if (Toast._toasts.length === 0) { + Toast._removeContainer(); } }, this.options.outDuration); } diff --git a/components/tabs/tabs.test.ts b/components/tabs/tabs.test.ts new file mode 100644 index 0000000000..c19ce35a16 --- /dev/null +++ b/components/tabs/tabs.test.ts @@ -0,0 +1,195 @@ +// @vitest-environment happy-dom +import { describe, beforeEach, afterEach, it, expect, vi } from 'vitest'; +import { Tabs } from './tabs.ts'; + +// Helper utility for async delays +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +describe('Tabs Plugin', () => { + const fixture = `
    + +
    Test 1
    +
    Test 2
    +
    Test 3
    +
    Test 4
    +
    Test 5
    +
    Test 6
    +
    Test 7
    +
    Test 8
    +
    `; + + let container: HTMLDivElement; + + beforeEach(() => { + // Mount fixture + container = document.createElement('div'); + container.innerHTML = fixture; + document.body.appendChild(container); + + const normalTabs = document.querySelector('.tabs.normal')!; + Tabs.init(normalTabs, { duration: 0 }); + window.location.hash = ''; + + // Force selection of test2 as active tab + Tabs.getInstance(normalTabs)?.select('test2'); + }); + + afterEach(() => { + const normalTabs = document.querySelector('.tabs.normal'); + if (normalTabs) { + Tabs.getInstance(normalTabs)?.destroy(); + } + document.body.innerHTML = ''; + }); + + describe('Tabs', () => { + it('should open to active tab', () => { + const normalTabs = document.querySelector('.tabs.normal')!; + const activeTab = normalTabs.querySelector('.active')!; + const activeTabHash = activeTab.getAttribute('href')!; + const tabLinks = normalTabs.querySelectorAll('.tab a'); + + tabLinks.forEach((tabLink) => { + const tabHash = tabLink.getAttribute('href')!; + const contentEl = document.querySelector(tabHash)!; + + if (tabHash === activeTabHash) { + expect(contentEl.style.display).not.toBe('none'); + } else { + expect(contentEl.style.display).toBe('none'); + } + }); + + const indicator = normalTabs.querySelector('.indicator'); + expect(indicator).not.toBeNull(); + }); + + it('should switch to clicked tab', async () => { + const normalTabs = document.querySelector('.tabs.normal')!; + const activeTab = normalTabs.querySelector('.active')!; + const activeTabHash = activeTab.getAttribute('href')!; + const disabledTab = normalTabs.querySelector('.disabled a')!; + const disabledTabHash = disabledTab.getAttribute('href')!; + const firstTab = normalTabs.querySelector('.tab a')!; + const firstTabHash = firstTab.getAttribute('href')!; + const indicator = normalTabs.querySelector('.indicator')!; + + expect(indicator).not.toBeNull(); + + // Click disabled tab + disabledTab.click(); + await delay(10); + + const activeContent = document.querySelector(activeTabHash)!; + const disabledContent = document.querySelector(disabledTabHash)!; + + expect(activeContent.style.display).not.toBe('none'); + expect(disabledContent.style.display).toBe('none'); + + // Click first tab + firstTab.click(); + await delay(10); + + const firstContent = document.querySelector(firstTabHash)!; + + expect(activeContent.style.display).toBe('none'); + expect(firstContent.style.display).not.toBe('none'); + expect(indicator.offsetLeft).toBe(firstTab.offsetLeft); + }); + + it("shouldn't hide active tab if clicked while active", async () => { + const normalTabs = document.querySelector('.tabs.normal')!; + const activeTab = normalTabs.querySelector('.active')!; + const activeTabHash = activeTab.getAttribute('href')!; + const indicator = normalTabs.querySelector('.indicator'); + + expect(indicator).not.toBeNull(); + + activeTab.click(); + await delay(10); + + const activeContent = document.querySelector(activeTabHash)!; + expect(activeContent.style.display).not.toBe('none'); + }); + + it('should horizontally scroll when too many tabs', () => { + const normalTabs = document.querySelector('.tabs.normal')!; + const tabsInstance = Tabs.getInstance(normalTabs); + // Mock container visible width (400px) and total scroll width (800px) + vi.spyOn(normalTabs, 'getBoundingClientRect').mockReturnValue({ + width: 400, + height: 50, + top: 0, + left: 0, + bottom: 50, + right: 400, + x: 0, + y: 0, + toJSON: () => {} + }); + Object.defineProperty(normalTabs, 'scrollWidth', { + configurable: true, + value: 800 + }); + // Trigger recalculation + tabsInstance._setTabsAndTabWidth(); + // Verify internal width state + expect(normalTabs.scrollWidth).toBeGreaterThan(tabsInstance._tabsWidth); + expect(tabsInstance._tabWidth).toBe(100); // 800px scrollWidth / 8 tabs + }); + + it('should programmatically switch tabs', async () => { + const normalTabs = document.querySelector('.tabs.normal')!; + const activeTab = normalTabs.querySelector('.active')!; + const activeTabHash = activeTab.getAttribute('href')!; + const firstTab = normalTabs.querySelector('li a')!; + const firstTabHash = firstTab.getAttribute('href')!; + const indicator = normalTabs.querySelector('.indicator')!; + const tabs = normalTabs.querySelectorAll('.tab a'); + + tabs.forEach((tab) => { + const tabHash = tab.getAttribute('href')!; + const contentEl = document.querySelector(tabHash)!; + + if (tabHash === activeTabHash) { + expect(contentEl.style.display).not.toBe('none'); + } else { + expect(contentEl.style.display).toBe('none'); + } + }); + + Tabs.getInstance(normalTabs)?.select('test1'); + await delay(10); + + const activeContent = document.querySelector(activeTabHash)!; + const firstContent = document.querySelector(firstTabHash)!; + + expect(activeContent.style.display).toBe('none'); + expect(firstContent.style.display).not.toBe('none'); + expect(indicator.offsetLeft).toBe(firstTab.offsetLeft); + }); + + it("shouldn't error if tab has no associated content", async () => { + document.querySelector('#test8')?.remove(); + + const tabNoContent = document.querySelector('[href="#test8"]')!; + expect(tabNoContent.classList.contains('active')).toBe(false); + + tabNoContent.click(); + await delay(10); + + expect(tabNoContent.classList.contains('active')).toBe(true); + }); + }); +}); diff --git a/components/tabs/tabs.ts b/components/tabs/tabs.ts index 50af294804..2a89f4a7e4 100644 --- a/components/tabs/tabs.ts +++ b/components/tabs/tabs.ts @@ -21,7 +21,7 @@ interface TabsOptions extends BaseOptions { /** * The maximum width of the screen, in pixels, * where the swipeable functionality initializes. - * @default infinity + * @default Infinity */ responsiveThreshold: number; } @@ -30,7 +30,7 @@ const _defaults: TabsOptions = { duration: 300, onShow: null, swipeable: false, - responsiveThreshold: Infinity // breakpoint for swipeable + responsiveThreshold: Infinity }; class Tabs extends Component { @@ -55,11 +55,13 @@ class Tabs extends Component { this._tabLinks = this.el.querySelectorAll('li.tab > a'); this._index = 0; this._setupActiveTabLink(); + if (this.options.swipeable) { this._setupSwipeableTabs(); } else { this._setupNormalTabs(); } + // Setup tabs indicator after content to ensure accurate widths this._setTabsAndTabWidth(); this._createIndicator(); @@ -70,23 +72,8 @@ class Tabs extends Component { return _defaults; } - /** - * Initializes instance of Tabs. - * @param el HTML element. - * @param options Component options. - */ static init(el: HTMLElement, options?: Partial): Tabs; - /** - * Initializes instances of Tabs. - * @param els HTML elements. - * @param options Component options. - */ static init(els: InitElements, options?: Partial): Tabs[]; - /** - * Initializes instances of Tabs. - * @param els HTML elements. - * @param options Component options. - */ static init( els: HTMLElement | InitElements, options: Partial = {} @@ -100,7 +87,9 @@ class Tabs extends Component { destroy() { this._removeEventHandlers(); - this._indicator.parentNode.removeChild(this._indicator); + if (this._indicator?.parentNode) { + this._indicator.parentNode.removeChild(this._indicator); + } if (this.options.swipeable) { this._teardownSwipeableTabs(); } else { @@ -109,9 +98,6 @@ class Tabs extends Component { this.el['M_Tabs'] = undefined; } - /** - * The index of tab that is currently shown. - */ get index() { return this._index; } @@ -119,23 +105,25 @@ class Tabs extends Component { _setupEventHandlers() { window.addEventListener('resize', this._handleWindowResize); this.el.addEventListener('click', this._handleTabClick); - // map vertical scrolling to horizontal scrolling because no scrollbar is shown on desktop - this.el.addEventListener('wheel', (event) => { - if (event.deltaY !== 0) { - event.preventDefault(); - this.el.scrollLeft += event.deltaY * 1; - } - }); + this.el.addEventListener('wheel', this._handleWheel); } _removeEventHandlers() { window.removeEventListener('resize', this._handleWindowResize); this.el.removeEventListener('click', this._handleTabClick); + this.el.removeEventListener('wheel', this._handleWheel); } + _handleWheel = (event: WheelEvent) => { + if (event.deltaY !== 0) { + event.preventDefault(); + this.el.scrollLeft += event.deltaY; + } + }; + _handleWindowResize = () => { this._setTabsAndTabWidth(); - if (this._tabWidth !== 0 && this._tabsWidth !== 0) { + if (this._tabWidth !== 0 && this._tabsWidth !== 0 && this._activeTabLink) { this._indicator.style.left = this._calcLeftPos(this._activeTabLink) + 'px'; this._indicator.style.right = this._calcRightPos(this._activeTabLink) + 'px'; } @@ -151,49 +139,46 @@ class Tabs extends Component { tab = tab.parentElement; } - // Handle click on tab link only - if (!tabLink || !tab.classList.contains('tab')) return; - // is disabled? + if (!tabLink || !tab?.classList.contains('tab')) return; if (tab.classList.contains('disabled')) { e.preventDefault(); return; } - // Act as regular link if target attribute is specified. if (tabLink.hasAttribute('target')) return; - // Make the old tab inactive. - this._activeTabLink.classList.remove('active'); + + this._activeTabLink?.classList.remove('active'); const _oldContent = this._content; - // Update the variables with the new link and content this._activeTabLink = tabLink; if (tabLink.hash) this._content = document.querySelector(tabLink.hash); this._tabLinks = this.el.querySelectorAll('li.tab > a'); - // Make the tab active + this._activeTabLink.classList.add('active'); const prevIndex = this._index; this._index = Math.max(Array.from(this._tabLinks).indexOf(tabLink), 0); - // Swap content if (this.options.swipeable) { if (this._tabsCarousel) { this._tabsCarousel.set(this._index, () => { - if (typeof this.options.onShow === 'function') + if (typeof this.options.onShow === 'function') { this.options.onShow.call(this, this._content); + } }); } } else { if (this._content) { this._content.style.display = 'block'; this._content.classList.add('active'); - if (typeof this.options.onShow === 'function') + if (typeof this.options.onShow === 'function') { this.options.onShow.call(this, this._content); + } if (_oldContent && _oldContent !== this._content) { _oldContent.style.display = 'none'; _oldContent.classList.remove('active'); } } } - // Update widths after content is swapped (scrollbar bugfix) + this._setTabsAndTabWidth(); this._animateIndicator(prevIndex); e.preventDefault(); @@ -204,16 +189,17 @@ class Tabs extends Component { indicator.classList.add('indicator'); this.el.appendChild(indicator); this._indicator = indicator; - this._indicator.style.left = this._calcLeftPos(this._activeTabLink) + 'px'; - this._indicator.style.right = this._calcRightPos(this._activeTabLink) + 'px'; + if (this._activeTabLink) { + this._indicator.style.left = this._calcLeftPos(this._activeTabLink) + 'px'; + this._indicator.style.right = this._calcRightPos(this._activeTabLink) + 'px'; + } } _setupActiveTabLink() { - // If the location.hash matches one of the links, use that as the active tab. this._activeTabLink = Array.from(this._tabLinks).find( (a: HTMLAnchorElement) => a.getAttribute('href') === location.hash ); - // If no match is found, use the first link or any with class 'active' as the initial active tab. + if (!this._activeTabLink) { let activeTabLink = this.el.querySelector('li.tab a.active'); if (!activeTabLink) { @@ -221,43 +207,49 @@ class Tabs extends Component { } this._activeTabLink = activeTabLink as HTMLAnchorElement; } + Array.from(this._tabLinks).forEach((a: HTMLAnchorElement) => a.classList.remove('active')); - this._activeTabLink.classList.add('active'); - this._index = Math.max(Array.from(this._tabLinks).indexOf(this._activeTabLink), 0); - if (this._activeTabLink && this._activeTabLink.hash) { - this._content = document.querySelector(this._activeTabLink.hash); - if (this._content) this._content.classList.add('active'); + if (this._activeTabLink) { + this._activeTabLink.classList.add('active'); + this._index = Math.max(Array.from(this._tabLinks).indexOf(this._activeTabLink), 0); + if (this._activeTabLink.hash) { + this._content = document.querySelector(this._activeTabLink.hash); + if (this._content) this._content.classList.add('active'); + } } } _setupSwipeableTabs() { - // Change swipeable according to responsive threshold - if (window.innerWidth > this.options.responsiveThreshold) this.options.swipeable = false; + if (window.innerWidth > this.options.responsiveThreshold) { + this.options.swipeable = false; + return; + } - const tabsContent = []; + const tabsContent: HTMLElement[] = []; this._tabLinks.forEach((a) => { if (a.hash) { - const currContent = document.querySelector(a.hash); - currContent.classList.add('carousel-item'); - tabsContent.push(currContent); + const currContent = document.querySelector(a.hash) as HTMLElement; + if (currContent) { + currContent.classList.add('carousel-item'); + tabsContent.push(currContent); + } } }); - // Create Carousel-Wrapper around Tab-Contents + if (tabsContent.length === 0) return; + const tabsWrapper = document.createElement('div'); tabsWrapper.classList.add('tabs-content', 'carousel', 'carousel-slider'); - // Wrap around tabsContent[0].parentElement.insertBefore(tabsWrapper, tabsContent[0]); tabsContent.forEach((tabContent) => { tabsWrapper.appendChild(tabContent); tabContent.style.display = ''; }); - // Keep active tab index to set initial carousel slide - const tab = this._activeTabLink.parentElement; - const activeTabIndex = Array.from(tab.parentNode.children).indexOf(tab); + const tab = this._activeTabLink?.parentElement; + const activeTabIndex = tab ? Array.from(tab.parentNode.children).indexOf(tab) : 0; this._tabsCarousel = Carousel.init(tabsWrapper, { fullWidth: true, @@ -265,39 +257,42 @@ class Tabs extends Component { onCycleTo: (item) => { const prevIndex = this._index; this._index = Array.from(item.parentNode.children).indexOf(item); - this._activeTabLink.classList.remove('active'); + this._activeTabLink?.classList.remove('active'); this._activeTabLink = Array.from(this._tabLinks)[this._index]; - this._activeTabLink.classList.add('active'); + this._activeTabLink?.classList.add('active'); this._animateIndicator(prevIndex); - if (typeof this.options.onShow === 'function') + if (typeof this.options.onShow === 'function') { this.options.onShow.call(this, this._content); + } } }); - // Set initial carousel slide to active tab + this._tabsCarousel.set(activeTabIndex); } _teardownSwipeableTabs() { + if (!this._tabsCarousel) return; const tabsWrapper = this._tabsCarousel.el; this._tabsCarousel.destroy(); - // Unwrap - tabsWrapper.append(tabsWrapper.parentElement); + + // Move children back out to the parent container + while (tabsWrapper.firstChild) { + tabsWrapper.parentElement.insertBefore(tabsWrapper.firstChild, tabsWrapper); + } tabsWrapper.remove(); } _setupNormalTabs() { - // Hide Tabs Content Array.from(this._tabLinks).forEach((a) => { if (a === this._activeTabLink) return; - if ((a).hash) { - const currContent = document.querySelector((a).hash); - if (currContent) (currContent).style.display = 'none'; + if (a.hash) { + const currContent = document.querySelector(a.hash) as HTMLElement; + if (currContent) currContent.style.display = 'none'; } }); } _teardownNormalTabs() { - // show Tabs Content this._tabLinks.forEach((a) => { if (a.hash) { const currContent = document.querySelector(a.hash) as HTMLElement; @@ -308,7 +303,10 @@ class Tabs extends Component { _setTabsAndTabWidth() { this._tabsWidth = this.el.getBoundingClientRect().width; - this._tabWidth = Math.max(this._tabsWidth, this.el.scrollWidth) / this._tabLinks.length; + this._tabWidth = + this._tabLinks.length > 0 + ? Math.max(this._tabsWidth, this.el.scrollWidth) / this._tabLinks.length + : 0; } _calcRightPos(el: HTMLElement) { @@ -319,24 +317,21 @@ class Tabs extends Component { return Math.floor(el.offsetLeft); } - /** - * Recalculate tab indicator position. This is useful when - * the indicator position is not correct. - */ updateTabIndicator() { this._setTabsAndTabWidth(); this._animateIndicator(this._index); } _animateIndicator(prevIndex: number) { - let leftDelay = 0, - rightDelay = 0; + if (!this._indicator || !this._activeTabLink) return; + + let leftDelay = 0; + let rightDelay = 0; const isMovingLeftOrStaying = this._index - prevIndex >= 0; if (isMovingLeftOrStaying) leftDelay = 90; else rightDelay = 90; - // in v1: easeOutQuad this._indicator.style.transition = ` left ${this.options.duration}ms ease-out ${leftDelay}ms, right ${this.options.duration}ms ease-out ${rightDelay}ms`; @@ -345,15 +340,11 @@ class Tabs extends Component { this._indicator.style.right = this._calcRightPos(this._activeTabLink) + 'px'; } - /** - * Show tab content that corresponds to the tab with the id. - * @param tabId The id of the tab that you want to switch to. - */ select(tabId: string) { const tab = Array.from(this._tabLinks).find( (a: HTMLAnchorElement) => a.getAttribute('href') === '#' + tabId ); - if (tab) (tab).click(); + if (tab) tab.click(); } } diff --git a/components/tabs/tabsSpec.js b/components/tabs/tabsSpec.js deleted file mode 100644 index 93427b6a80..0000000000 --- a/components/tabs/tabsSpec.js +++ /dev/null @@ -1,180 +0,0 @@ -describe('Tabs Plugin', () => { - const fixture = `
    - -
    Test 1
    -
    Test 2
    -
    Test 3
    -
    Test 4
    -
    Test 1
    -
    Test 2
    -
    Test 3
    -
    Test 4
    -
    `; - - beforeEach(() => { - XloadHtml(fixture); - const normalTabs = document.querySelector('.tabs.normal'); - M.Tabs.init(normalTabs, { duration: 0 }); - window.location.hash = ''; - //HACK the tabs init function not fully initializing. it restores state even after element has been removed from DOM, even after using tabInstance.destroy() - M.Tabs.getInstance(normalTabs).select('test2'); - }); - afterEach(() => XunloadFixtures()); - - describe('Tabs', () => { - it('should open to active tab', () => { - const normalTabs = document.querySelector('.tabs.normal'); - const activeTab = normalTabs.querySelector('.active'); - const activeTabHash = activeTab.getAttribute('href'); - const tabLinks = normalTabs.querySelectorAll('.tab a'); - for (let i = 0; i < tabLinks.length; i++) { - const tabHash = tabLinks[i].getAttribute('href'); - if (tabHash === activeTabHash) { - expect(document.querySelector(tabHash)).toBeVisible( - 'active tab content should be visible by default' - ); //TODO replace with alternative for deprecated jasmine-jquery - } else { - expect(document.querySelector(tabHash)).toBeHidden( - 'Tab content should be hidden by default' - ); //TODO replace with alternative for deprecated jasmine-jquery - } - } - const indicator = normalTabs.querySelector('.indicator'); - expect(indicator).toExist('Indicator should be generated'); - // expect(Math.abs(indicator.offset().left - activeTab.offset().left)).toBeLessThan(1, 'Indicator should be at active tab by default.'); - }); - - it('should switch to clicked tab', (done) => { - const normalTabs = document.querySelector('.tabs.normal'); - const activeTab = normalTabs.querySelector('.active'); - const activeTabHash = activeTab.getAttribute('href'); - const disabledTab = normalTabs.querySelector('.disabled a'); - const disabledTabHash = disabledTab.getAttribute('href'); - const firstTab = normalTabs.querySelector('.tab a'); - const firstTabHash = firstTab.getAttribute('href'); - const indicator = normalTabs.querySelector('.indicator'); - expect(indicator).toExist('Indicator should be generated'); - // expect(Math.abs(indicator.offset().left - activeTab.offset().left)).toBeLessThan(1, 'Indicator should be at active tab by default.'); - click(disabledTab); - setTimeout(() => { - expect(document.querySelector(activeTabHash)).toBeVisible( - 'Clicking disabled should not change tabs.' - ); //TODO replace with alternative for deprecated jasmine-jquery - expect(document.querySelector(disabledTabHash)).toBeHidden( - 'Clicking disabled should not change tabs.' - ); //TODO replace with alternative for deprecated jasmine-jquery - - click(firstTab); - - setTimeout(() => { - expect(document.querySelector(activeTabHash)).toBeHidden( - 'Clicking tab should switch to that tab.' - ); //TODO replace with alternative for deprecated jasmine-jquery - expect(document.querySelector(firstTabHash)).toBeVisible( - 'Clicking tab should switch to that tab.' - ); //TODO replace with alternative for deprecated jasmine-jquery - expect(indicator.offsetLeft).toEqual( - firstTab.offsetLeft, - 'Indicator should move to clicked tab.' - ); - done(); - }, 10); // 400 - }, 10); // 400 - }); - - it("shouldn't hide active tab if clicked while active", (done) => { - const normalTabs = document.querySelector('.tabs.normal'); - const activeTab = normalTabs.querySelector('.active'); - const activeTabHash = activeTab.getAttribute('href'); - const indicator = normalTabs.querySelector('.indicator'); - expect(indicator).toExist('Indicator should be generated'); - click(activeTab); - setTimeout(() => { - expect(document.querySelector(activeTabHash)).toBeVisible( - 'Clicking active tab while active should not hide it.' - ); - done(); - }, 5); // 400 - }); - - it('should horizontally scroll when too many tabs', (done) => { - let tabsScrollWidth = 0; - const normalTabs = document.querySelector('.tabs.normal'); - normalTabs.style.width = '400px'; - const tabs = normalTabs.querySelectorAll('.tab'); - for (let i = 0; i < tabs.length; i++) { - setTimeout(() => { - tabsScrollWidth += tabs[i].offsetWidth; - }, 0); - } - - setTimeout(() => { - expect(tabsScrollWidth).toBeGreaterThan( - normalTabs.offsetWidth, - 'Scroll width should exceed tabs width' - ); - done(); - }, 5); // 400 - }); - - it('should programmatically switch tabs', (done) => { - const normalTabs = document.querySelector('.tabs.normal'); - const activeTab = normalTabs.querySelector('.active'); - const activeTabHash = activeTab.getAttribute('href'); - const firstTab = normalTabs.querySelector('li a'); - const firstTabHash = firstTab.getAttribute('href'); - const indicator = normalTabs.querySelector('.indicator'); - const tabs = normalTabs.querySelectorAll('.tab a'); - for (let i = 0; i < tabs.length; i++) { - const tabHash = tabs[i].getAttribute('href'); - if (tabHash === activeTabHash) { - expect(document.querySelector(tabHash)).toBeVisible( - 'active tab content should be visible by default' - ); //TODO replace with alternative for deprecated jasmine-jquery - } else { - expect(document.querySelector(tabHash)).toBeHidden( - 'Tab content should be hidden by default' - ); //TODO replace with alternative for deprecated jasmine-jquery - } - } - - M.Tabs.getInstance(normalTabs).select('test1'); - - setTimeout(() => { - expect(document.querySelector(activeTabHash)).toBeHidden( - 'Clicking tab should switch to that tab.' - ); //TODO replace with alternative for deprecated jasmine-jquery - expect(document.querySelector(firstTabHash)).toBeVisible( - 'Clicking tab should switch to that tab.' - ); //TODO replace with alternative for deprecated jasmine-jquery - expect(indicator.offsetLeft).toEqual( - firstTab.offsetLeft, - 'Indicator should move to clicked tab.' - ); - done(); - }, 5); // 400 - }); - - it("shouldn't error if tab has no associated content", (done) => { - document.querySelector('#test8').remove(); - const tabNoContent = document.querySelector('[href="#test8"]'); - expect(tabNoContent).toNotHaveClass('active', 'Tab should not be selected'); - click(tabNoContent); - setTimeout(() => { - expect(tabNoContent).toHaveClass('active', 'Tab should be selected even with no content'); - done(); - }, 10); // 400 - }); - }); -}); diff --git a/components/textfield/formsSpec.js b/components/textfield/___formsSpec.js similarity index 100% rename from components/textfield/formsSpec.js rename to components/textfield/___formsSpec.js diff --git a/components/textfield/selectSpec.js b/components/textfield/___selectSpec.js similarity index 100% rename from components/textfield/selectSpec.js rename to components/textfield/___selectSpec.js diff --git a/components/tooltip/_tooltip.scss b/components/tooltip/___del_tooltip.scss similarity index 100% rename from components/tooltip/_tooltip.scss rename to components/tooltip/___del_tooltip.scss diff --git a/components/tooltip/tooltip.css b/components/tooltip/tooltip.css new file mode 100644 index 0000000000..9bb29ca7bd --- /dev/null +++ b/components/tooltip/tooltip.css @@ -0,0 +1,56 @@ +/* Reset popover default browser styles */ +.material-tooltip[popover] { + margin: 0; + border: none; + padding: 6px 10px; + background: #323232; + color: #fff; + border-radius: 4px; + font-size: 12px; + + /* Modern CSS Anchor Positioning */ + position: fixed; + position-fallback: --tooltip-fallback; + + /* Transition/Animation Handling */ + opacity: 0; + transition: + opacity 0.2s ease-out, + display 0.2s allow-discrete; +} + +/* Style for open state */ +.material-tooltip[popover]:popover-open { + opacity: 1; +} + +@starting-style { + .material-tooltip[popover]:popover-open { + opacity: 0; + } +} + +/* Position mapping using anchor positioning */ +.material-tooltip[data-position='top'] { + bottom: anchor(top); + justify-self: anchor-center; + margin-bottom: 5px; +} + +.material-tooltip[data-position='bottom'] { + top: anchor(bottom); + justify-self: anchor-center; + margin-top: 5px; +} + +.material-tooltip[data-position='left'] { + right: anchor(left); + align-self: anchor-center; + margin-right: 5px; +} + +.material-tooltip[data-position='right'] { + left: anchor(right); + align-self: anchor-center; + margin-left: 5px; +} diff --git a/components/tooltip/tooltip.test.ts b/components/tooltip/tooltip.test.ts new file mode 100644 index 0000000000..75ddd54dcf --- /dev/null +++ b/components/tooltip/tooltip.test.ts @@ -0,0 +1,129 @@ +// @vitest-environment happy-dom +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { Tooltip } from './tooltip'; + +const fixture = ` + + Hover me! + + + + Hover me! + + + +`; + +describe('Tooltip (Popover API)', () => { + beforeEach(() => { + // Setup fake timers for delay assertions + vi.useFakeTimers(); + + // Polyfill popover methods if Happy-DOM environment does not support native Popover API yet + if (!HTMLElement.prototype.showPopover) { + HTMLElement.prototype.showPopover = vi.fn(function (this: HTMLElement) { + this.setAttribute('popover-open', ''); + this.matches = (selector: string) => + selector === ':popover-open' || selector === '[popover]'; + }); + HTMLElement.prototype.hidePopover = vi.fn(function (this: HTMLElement) { + this.removeAttribute('popover-open'); + this.matches = (selector: string) => selector === '[popover]'; + }); + } + + // Set up document fixture + document.body.innerHTML = fixture; + + // Initialize tooltips + Tooltip.init(document.querySelectorAll('.tooltipped'), { + enterDelay: 0, + exitDelay: 0 + }); + }); + + afterEach(() => { + document.body.innerHTML = ''; + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + describe('opens and closes properly', () => { + it('shows tooltip on mouse enter and hides on mouse leave', () => { + const tooltippedBtn = document.querySelector('#test')!; + const instance = Tooltip.getInstance(tooltippedBtn); + const tooltip = instance.tooltipEl; + + // Simulate mouse enter + tooltippedBtn.dispatchEvent(new Event('mouseenter')); + vi.advanceTimersByTime(10); + + // Verify content and open state + expect(instance.isOpen).toBe(true); + expect(tooltip.textContent).toBe('I am tooltip'); + + // Simulate mouse leave + tooltippedBtn.dispatchEvent(new Event('mouseleave')); + vi.advanceTimersByTime(10); + + expect(instance.isOpen).toBe(false); + }); + + it('removes tooltip DOM object on destroy', () => { + const tooltippedBtn = document.querySelector('#test1')!; + const instance = Tooltip.getInstance(tooltippedBtn); + const tooltipEl = instance.tooltipEl; + + instance.destroy(); + + // Check DOM cleanup & instance ref clearance + expect(Tooltip.getInstance(tooltippedBtn)).toBeUndefined(); + expect(document.body.contains(tooltipEl)).toBe(false); + }); + + it('changes position attribute dynamically', () => { + const tooltippedBtn = document.querySelector('#test')!; + tooltippedBtn.setAttribute('data-position', 'right'); + + const instance = Tooltip.getInstance(tooltippedBtn); + + tooltippedBtn.dispatchEvent(new Event('mouseenter')); + vi.advanceTimersByTime(10); + + expect(instance.tooltipEl.dataset.position).toBe('right'); + }); + + it('accepts delay option from javascript initialization', () => { + const tooltippedBtn = document.querySelector('#test')!; + tooltippedBtn.removeAttribute('data-delay'); + + // Re-initialize with a custom enterDelay + const instance = Tooltip.init(tooltippedBtn, { enterDelay: 200 }); + + tooltippedBtn.dispatchEvent(new Event('mouseenter')); + + // Check before delay completes + vi.advanceTimersByTime(150); + expect(instance.isOpen).toBe(false); + + // Check after delay passes + vi.advanceTimersByTime(100); + expect(instance.isOpen).toBe(true); + }); + + it('assigns correctly with a fixed position parent', () => { + const tooltippedBtn = document.querySelector('#test2')!; + const instance = Tooltip.getInstance(tooltippedBtn); + + tooltippedBtn.dispatchEvent(new Event('mouseenter')); + vi.advanceTimersByTime(10); + + expect(instance.isOpen).toBe(true); + expect(instance.tooltipEl.dataset.position).toBe('bottom'); + }); + }); +}); diff --git a/components/tooltip/tooltip.ts b/components/tooltip/tooltip.ts index dab16f88c8..7a05e74dec 100644 --- a/components/tooltip/tooltip.ts +++ b/components/tooltip/tooltip.ts @@ -1,101 +1,35 @@ -import { Utils } from '../../src/utils'; import { Component, BaseOptions, InitElements, MElement } from '../../src/component'; -// TODO: Rewrite this using popover and js only - -class Bounding { - left: number; // left offset coordinate - top: number; - width: number; - height: number; -} - export type TooltipPosition = 'top' | 'right' | 'bottom' | 'left'; export interface TooltipOptions extends BaseOptions { - /** - * Delay time before tooltip disappears. - * @default 200 - */ + /** Delay before tooltip disappears (ms) */ exitDelay: number; - /** - * Delay time before tooltip appears. - * @default 0 - */ + /** Delay before tooltip appears (ms) */ enterDelay: number; - /** - * Element Id for the tooltip. - * @default "" - */ + /** Custom element ID for existing content */ tooltipId?: string; - /** - * Text string for the tooltip. - * @default "" - */ + /** Text content for the tooltip */ text: string; - /** - * Set distance tooltip appears away from its activator - * excluding transitionMovement. - * @default 5 - */ - margin: number; - /** - * Enter transition duration. - * @default 300 - */ - inDuration: number; - /** - * Opacity of the tooltip. - * @default 1 - */ - opacity: number; - /** - * Exit transition duration. - * @default 250 - */ - outDuration: number; - /** - * Set the direction of the tooltip. - * @default 'bottom' - */ + /** Position relative to the anchor */ position: TooltipPosition; - /** - * Amount in px that the tooltip moves during its transition. - * @default 10 - */ - transitionMovement: number; } const _defaults: TooltipOptions = { exitDelay: 200, enterDelay: 0, text: '', - margin: 5, - inDuration: 250, - outDuration: 200, - position: 'bottom' as TooltipPosition, - transitionMovement: 10, - opacity: 1 + position: 'bottom' }; export class Tooltip extends Component { - /** - * If tooltip is open. - */ - isOpen: boolean; - /** - * If tooltip is hovered. - */ - isHovered: boolean; - /** - * If tooltip is focused. - */ - isFocused: boolean; + isOpen: boolean = false; + isHovered: boolean = false; + isFocused: boolean = false; + tooltipEl: HTMLElement; - private _exitDelayTimeout: string | number | NodeJS.Timeout; - private _enterDelayTimeout: string | number | NodeJS.Timeout; - xMovement: number; - yMovement: number; + private _enterTimeout: number | NodeJS.Timeout; + private _exitTimeout: number | NodeJS.Timeout; constructor(el: HTMLElement, options: Partial) { super(el, options, Tooltip); @@ -107,10 +41,7 @@ export class Tooltip extends Component { ...options }; - this.isOpen = false; - this.isHovered = false; - this.isFocused = false; - this._appendTooltipEl(); + this._setupPopoverElement(); this._setupEventHandlers(); } @@ -118,23 +49,8 @@ export class Tooltip extends Component { return _defaults; } - /** - * Initializes instance of Tooltip. - * @param el HTML element. - * @param options Component options. - */ static init(el: HTMLElement, options?: Partial): Tooltip; - /** - * Initializes instances of Tooltip. - * @param els HTML elements. - * @param options Component options. - */ static init(els: InitElements, options?: Partial): Tooltip[]; - /** - * Initializes instances of Tooltip. - * @param els HTML elements. - * @param options Component options. - */ static init( els: HTMLElement | InitElements, options: Partial = {} @@ -149,237 +65,111 @@ export class Tooltip extends Component { destroy() { this.tooltipEl.remove(); this._removeEventHandlers(); - this.el['M_Tooltip'] = undefined; + delete this.el['M_Tooltip']; } - _appendTooltipEl() { - this.tooltipEl = document.createElement('div'); - this.tooltipEl.classList.add('material-tooltip'); + private _setupPopoverElement() { + if (this.options.tooltipId) { + this.tooltipEl = document.getElementById(this.options.tooltipId); + } else { + this.tooltipEl = document.createElement('div'); + this.tooltipEl.className = 'material-tooltip'; + this.tooltipEl.textContent = this.options.text; + document.body.appendChild(this.tooltipEl); + } - const tooltipContentEl = this.options.tooltipId - ? document.getElementById(this.options.tooltipId) - : document.createElement('div'); - this.tooltipEl.append(tooltipContentEl); - tooltipContentEl.style.display = ''; + // Set native popover attributes + this.tooltipEl.popover = 'manual'; - tooltipContentEl.classList.add('tooltip-content'); - this._setTooltipContent(tooltipContentEl); - this.tooltipEl.appendChild(tooltipContentEl); - document.body.appendChild(this.tooltipEl); - } - - _setTooltipContent(tooltipContentEl: HTMLElement) { - if (this.options.tooltipId) return; - tooltipContentEl.innerText = this.options.text; - } + // Assign unique anchor name linking the element and popover + const anchorName = `--tooltip-anchor-${Math.random().toString(36).substring(2, 9)}`; + (this.el.style as any).anchorName = anchorName; + (this.tooltipEl.style as any).positionAnchor = anchorName; - _updateTooltipContent() { - this._setTooltipContent(this.tooltipEl.querySelector('.tooltip-content')); + // Set default position class/data-attribute for CSS positioning + this.tooltipEl.dataset.position = this.options.position; } - _setupEventHandlers() { + private _setupEventHandlers() { this.el.addEventListener('mouseenter', this._handleMouseEnter); this.el.addEventListener('mouseleave', this._handleMouseLeave); - this.el.addEventListener('focus', this._handleFocus, true); - this.el.addEventListener('blur', this._handleBlur, true); + this.el.addEventListener('focus', this._handleFocus); + this.el.addEventListener('blur', this._handleBlur); } - _removeEventHandlers() { + private _removeEventHandlers() { this.el.removeEventListener('mouseenter', this._handleMouseEnter); this.el.removeEventListener('mouseleave', this._handleMouseLeave); - this.el.removeEventListener('focus', this._handleFocus, true); - this.el.removeEventListener('blur', this._handleBlur, true); + this.el.removeEventListener('focus', this._handleFocus); + this.el.removeEventListener('blur', this._handleBlur); } - /** - * Show tooltip. - */ - open = (isManual: boolean) => { + open = (isManual = true) => { if (this.isOpen) return; - isManual = isManual === undefined ? true : undefined; // Default value true - this.isOpen = true; - // Update tooltip content with HTML attribute options + + clearTimeout(this._exitTimeout); this.options = { ...this.options, ...this._getAttributeOptions() }; - this._updateTooltipContent(); - this._setEnterDelayTimeout(isManual); + this._updateContent(); + + this._enterTimeout = setTimeout(() => { + if (!isManual && !this.isHovered && !this.isFocused) return; + + this.tooltipEl.showPopover(); + this.isOpen = true; + }, this.options.enterDelay); }; - /** - * Hide tooltip. - */ close = () => { if (!this.isOpen) return; + + clearTimeout(this._enterTimeout); this.isHovered = false; this.isFocused = false; - this.isOpen = false; - this._setExitDelayTimeout(); - }; - _setExitDelayTimeout() { - clearTimeout(this._exitDelayTimeout); - this._exitDelayTimeout = setTimeout(() => { + this._exitTimeout = setTimeout(() => { if (this.isHovered || this.isFocused) return; - this._animateOut(); - }, this.options.exitDelay); - } - - _setEnterDelayTimeout(isManual) { - clearTimeout(this._enterDelayTimeout); - this._enterDelayTimeout = setTimeout(() => { - if (!this.isHovered && !this.isFocused && !isManual) return; - this._animateIn(); - }, this.options.enterDelay); - } - _positionTooltip() { - const tooltip: HTMLElement = this.tooltipEl; - const origin = this.el as HTMLElement, - originHeight = origin.offsetHeight, - originWidth = origin.offsetWidth, - tooltipHeight = tooltip.offsetHeight, - tooltipWidth = tooltip.offsetWidth, - margin = this.options.margin; - - this.xMovement = 0; - this.yMovement = 0; - - let targetTop = origin.getBoundingClientRect().top + Utils.getDocumentScrollTop(); - let targetLeft = origin.getBoundingClientRect().left + Utils.getDocumentScrollLeft(); - if (this.options.position === 'top') { - targetTop += -tooltipHeight - margin; - targetLeft += originWidth / 2 - tooltipWidth / 2; - this.yMovement = -this.options.transitionMovement; - } else if (this.options.position === 'right') { - targetTop += originHeight / 2 - tooltipHeight / 2; - targetLeft += originWidth + margin; - this.xMovement = this.options.transitionMovement; - } else if (this.options.position === 'left') { - targetTop += originHeight / 2 - tooltipHeight / 2; - targetLeft += -tooltipWidth - margin; - this.xMovement = -this.options.transitionMovement; - } else { - targetTop += originHeight + margin; - targetLeft += originWidth / 2 - tooltipWidth / 2; - this.yMovement = this.options.transitionMovement; - } - - const newCoordinates = this._repositionWithinScreen( - targetLeft, - targetTop, - tooltipWidth, - tooltipHeight - ); - - tooltip.style.top = newCoordinates.y + 'px'; - tooltip.style.left = newCoordinates.x + 'px'; - } - - _repositionWithinScreen(x: number, y: number, width: number, height: number) { - const scrollLeft = Utils.getDocumentScrollLeft(); - const scrollTop = Utils.getDocumentScrollTop(); - let newX = x - scrollLeft; - let newY = y - scrollTop; - - const bounding: Bounding = { - left: newX, - top: newY, - width: width, - height: height - }; - const offset = this.options.margin + this.options.transitionMovement; - const edges = Utils.checkWithinContainer(document.body, bounding, offset); + this.tooltipEl.hidePopover(); + this.isOpen = false; + }, this.options.exitDelay); + }; - if (edges.left) { - newX = offset; - } else if (edges.right) { - newX -= newX + width - window.innerWidth; + private _updateContent() { + if (!this.options.tooltipId) { + this.tooltipEl.textContent = this.options.text; } - if (edges.top) { - newY = offset; - } else if (edges.bottom) { - newY -= newY + height - window.innerHeight; - } - return { - x: newX + scrollLeft, - y: newY + scrollTop - }; + this.tooltipEl.dataset.position = this.options.position; } - _animateIn() { - this._positionTooltip(); - this.tooltipEl.style.visibility = 'visible'; - const duration = this.options.inDuration; - // easeOutCubic - this.tooltipEl.style.transition = ` - transform ${duration}ms ease-out, - opacity ${duration}ms ease-out`; - setTimeout(() => { - this.tooltipEl.style.transform = `translateX(${this.xMovement}px) translateY(${this.yMovement}px)`; - this.tooltipEl.style.opacity = (this.options.opacity || 1).toString(); - }, 1); - } - - _animateOut() { - const duration = this.options.outDuration; - // easeOutCubic - this.tooltipEl.style.transition = ` - transform ${duration}ms ease-out, - opacity ${duration}ms ease-out`; - setTimeout(() => { - this.tooltipEl.style.transform = `translateX(0px) translateY(0px)`; - this.tooltipEl.style.opacity = '0'; - }, 1); - /* - anim.remove(this.tooltipEl); - anim({ - targets: this.tooltipEl, - opacity: 0, - translateX: 0, - translateY: 0, - duration: this.options.outDuration, - easing: 'easeOutCubic' - }); - */ - } - - _handleMouseEnter = () => { + private _handleMouseEnter = () => { this.isHovered = true; - this.isFocused = false; // Allows close of tooltip when opened by focus. this.open(false); }; - _handleMouseLeave = () => { + private _handleMouseLeave = () => { this.isHovered = false; - this.isFocused = false; // Allows close of tooltip when opened by focus. this.close(); }; - _handleFocus = () => { - if (Utils.tabPressed) { - this.isFocused = true; - this.open(false); - } + private _handleFocus = () => { + this.isFocused = true; + this.open(false); }; - _handleBlur = () => { + private _handleBlur = () => { this.isFocused = false; this.close(); }; - _getAttributeOptions(): Partial { + private _getAttributeOptions(): Partial { const attributeOptions: Partial = {}; - const tooltipTextOption = this.el.getAttribute('data-tooltip'); + const text = this.el.getAttribute('data-tooltip'); + const position = this.el.getAttribute('data-position') as TooltipPosition; const tooltipId = this.el.getAttribute('data-tooltip-id'); - const positionOption = this.el.getAttribute('data-position'); - if (tooltipTextOption) { - attributeOptions.text = tooltipTextOption; - } - if (positionOption) { - attributeOptions.position = positionOption as TooltipPosition; - } - if (tooltipId) { - attributeOptions.tooltipId = tooltipId; - } + + if (text) attributeOptions.text = text; + if (position) attributeOptions.position = position; + if (tooltipId) attributeOptions.tooltipId = tooltipId; return attributeOptions; } diff --git a/components/tooltip/tooltipSpec.js b/components/tooltip/tooltipSpec.js deleted file mode 100644 index 5ed2697fde..0000000000 --- a/components/tooltip/tooltipSpec.js +++ /dev/null @@ -1,145 +0,0 @@ -describe('Tooltip:', () => { - const fixture = ` - Hover me! - - - - Hover me! - - -



















    -



















    -



















    -



















    - -`; - - beforeEach(() => { - XloadHtml(fixture); - M.Tooltip.init(document.querySelectorAll('.tooltipped'), { - enterDelay: 0, - exitDelay: 0, - inDuration: 0, - outDuration: 0 - }); - }); - afterEach(() => XunloadFixtures()); - - describe('opens and closes properly', () => { - it('shows tooltip on mouse enter', (done) => { - const tooltippedBtn = document.querySelector('#test'); - const tooltip = M.Tooltip.getInstance(tooltippedBtn).tooltipEl; - mouseenter(tooltippedBtn); - setTimeout(() => { - expect(tooltip).toBeVisible('because mouse entered tooltipped btn'); - expect(tooltip.querySelector('.tooltip-content').innerText).toBe( - 'I am tooltip', - 'because that is the defined text in the html attribute' - ); - mouseleave(tooltippedBtn); - setTimeout(() => { - expect(tooltip).toBeVisible('because mouse left tooltipped btn'); - done(); - }, 10); - }, 10); - }); - - it('should place tooltips on the bottom within the screen bounds', (done) => { - const tooltippedBtn = document.querySelector('#test1'); - const tooltip = M.Tooltip.getInstance(tooltippedBtn).tooltipEl; - mouseenter(tooltippedBtn); - // tooltippedBtn.trigger('mouseenter'); - setTimeout(() => { - const tooltipRect = tooltip.getBoundingClientRect(); - const tooltippedBtnRect = tooltippedBtn.getBoundingClientRect(); - // Check window bounds - expect(tooltipRect.top).toBeGreaterThanOrEqual(0); - expect(tooltipRect.bottom).toBeLessThanOrEqual(window.innerHeight); - expect(tooltipRect.left).toBeGreaterThanOrEqual(0); - expect(tooltipRect.right).toBeLessThanOrEqual(window.innerWidth); - // check if tooltip is under btn - expect(tooltipRect.top).toBeGreaterThan(tooltippedBtnRect.bottom); - done(); - }, 10); - }); - - it('removes tooltip dom object', () => { - const tooltippedBtn = document.querySelector('#test1'); - M.Tooltip.getInstance(tooltippedBtn).destroy(); - // Check DOM element is removed - const tooltipInstance = M.Tooltip.getInstance(tooltippedBtn); - expect(tooltipInstance).toBe(undefined); - }); - - it('changes position attribute dynamically and positions tooltips on the right correctly', (done) => { - const tooltippedBtn = document.querySelector('#test'); - tooltippedBtn.setAttribute('data-position', 'right'); - const tooltip = M.Tooltip.getInstance(tooltippedBtn).tooltipEl; - mouseenter(tooltippedBtn); - setTimeout(() => { - const tooltipRect = tooltip.getBoundingClientRect(); - const tooltippedBtnRect = tooltippedBtn.getBoundingClientRect(); - expect(tooltipRect.left).toBeGreaterThan(tooltippedBtnRect.right); - done(); - }, 10); - }); - - it('accepts delay option from javascript initialization', (done) => { - const tooltippedBtn = document.querySelector('#test'); - tooltippedBtn.removeAttribute('data-delay'); - M.Tooltip.init(tooltippedBtn, { enterDelay: 200 }); - const tooltip = M.Tooltip.getInstance(tooltippedBtn).tooltipEl; - mouseenter(tooltippedBtn); - setTimeout(() => { - const tooltipVisibility = getComputedStyle(tooltip).getPropertyValue('visibility'); - expect(tooltipVisibility).toBe('hidden', 'because the delay is 200 seconds'); - }, 150); - setTimeout(() => { - expect(tooltip).toBeVisible('because 200 seconds has passed'); - done(); - }, 250); - }); - - it('works with a fixed position parent', (done) => { - const tooltippedBtn = document.querySelector('#test2'); - const tooltip = M.Tooltip.getInstance(tooltippedBtn).tooltipEl; - mouseenter(tooltippedBtn); - setTimeout(() => { - const tooltipRect = tooltip.getBoundingClientRect(); - const tooltippedBtnRect = tooltippedBtn.getBoundingClientRect(); - const verticalDiff = tooltipRect.top - tooltippedBtnRect.top; - const horizontalDiff = - tooltipRect.left + - tooltipRect.width / 2 - - (tooltippedBtnRect.left + tooltippedBtnRect.width / 2); - // 52 is magic number for tooltip vertical offset... increased to 100 - expect(verticalDiff > 0 && verticalDiff < 100).toBeTruthy( - 'top position in fixed to be correct' - ); - expect(horizontalDiff > -1 && horizontalDiff < 1).toBeTruthy( - 'left position in fixed to be correct' - ); - done(); - }, 10); - }); - }); -}); diff --git a/examples/counter-app.iso.js b/examples/counter-app.iso.js new file mode 100644 index 0000000000..0e468d4e65 --- /dev/null +++ b/examples/counter-app.iso.js @@ -0,0 +1,64 @@ +import { Number, Text } from '../components/atomic/atomic.mjs'; +import { Card } from '../components/card/card.mjs'; +import { Button } from '../components/button/button.mjs'; + +function createCounterApp() { + return new Card({ + children: [ + new Text('My Counter').setTagName('div'), + new Number(9), + new Button('➕'), + new Button('➖') + ] + }).addClassname('p-3'); +} + +//====== Client Side Logic + +function count(input) { + const value = input.value + input.increment; + return { value }; +} + +function hydrate(domElement, initState = { value: 12 }) { + const state = initState; + + const btns = domElement.querySelectorAll('.btn'); // input + const numberEl = domElement.querySelector('.mw-number'); // output + + numberEl.innerHTML = state.value; + + btns[0].addEventListener('click', (e) => { + state.value = count({ value: state.value, increment: 1 }).value; + numberEl.innerHTML = state.value; + }); + btns[1].addEventListener('click', (e) => { + state.value = count({ value: state.value, increment: -1 }).value; + numberEl.innerHTML = state.value; + }); +} + +class CounterApp extends HTMLElement { + constructor() { + super(); + this.count = 0; + } + + connectedCallback() { + // Da das Template via DSD geladen wurde, existiert shadowRoot bereits! + if (this.shadowRoot) { + this.btn = this.shadowRoot.querySelector('#btn'); + this.output = this.shadowRoot.querySelector('#output'); + + // Event-Listener hinzufügen + this.btn.addEventListener('click', () => this.increment()); + } + } + + increment() { + this.count++; + this.output.textContent = this.count; + } +} + +export { createCounterApp, hydrate, CounterApp }; diff --git a/examples/html/blog.html b/examples/html/blog.html index 051d5f42e3..7a866f5280 100644 --- a/examples/html/blog.html +++ b/examples/html/blog.html @@ -1,17 +1,31 @@ - - Materialize Web Blog - - - - - -
    -

    Materialize Web Blog

    + + +
    +
    +
    +

    Materialize Web Blog

    +
    + + +

    01/01/2026 posted by Daniel

    Crafting Sleek, Responsive Interfaces with MaterializeWeb

    In the fast-evolving landscape of web development, building clean, responsive, and intuitive user interfaces remains a top priority. While major CSS frameworks like Bootstrap have long dominated the scene, MaterializeWeb—the community-driven evolution of MaterializeCSS—offers a refreshing design philosophy anchored directly in Google’s Material Design principles.

    @@ -26,7 +40,16 @@

    Key Features and Benefits

    Why Choose It for Your Next Project?

    If you want your web applications to mirror the sleek, unified visual identity of modern mobile apps without spending weeks writing custom styles, MaterializeWeb is an outstanding choice. It strikes an ideal balance between aesthetic polish and developer efficiency, making it perfect for rapid prototyping and production-ready sites alike.

    -

    Give MaterializeWeb a try on your next project to experience how effortless modern visual design can be!

    2 or 3-column masonry grid showing article thumbnails, titles, and publication dates
    This is a test Landingpage. ok it can produce nested html too...
    - Or you can go to the Portfolio
    Sidebar or navigation dropdowns ("Tech," "Lifestyle")
    -
    - +

    Give MaterializeWeb a try on your next project to experience how effortless modern visual design can be!

    +
    2 or 3-column masonry grid showing article thumbnails, titles, and publication dates
    + +This is a test Landingpage. ok it can produce nested html too...
    + Or you can go to the Portfolio
    +
    Sidebar or navigation dropdowns ("Tech," "Lifestyle")
    +
    + + + + + + diff --git a/examples/html/landingpage.html b/examples/html/landingpage.html index 275bce5c28..135ae854f0 100644 --- a/examples/html/landingpage.html +++ b/examples/html/landingpage.html @@ -1,13 +1,47 @@ - - My Landingpage - - - - - -
    -

    Landing Page

    Hero Section
    A bold "Hero" section at the top with a clear headline, followed by social proof (reviews/logos),
    + + + + + + + T-Shirt Landingpage + + + + + +
    +
    +

    a

    +
    +
    T-Shirt
    +
    A bold "Hero" section at the top with a clear headline, followed by social proof (reviews/logos),
    feature blocks, and multiple Calls- to - Action(CTAs) like "Sign Up" or "Buy Now.
    - You can also checkout the Blog
    Hello, this is my landing page!
    -
    - + You can also checkout the Blog + klöxökkökökxl +
    + + + + + +
    Hello, this is my landing page!
    + + + + + + diff --git a/examples/html/portfolio.html b/examples/html/portfolio.html index a4add8a257..532e795248 100644 --- a/examples/html/portfolio.html +++ b/examples/html/portfolio.html @@ -1,11 +1,23 @@ - - My Portfolio - - - - - -
    -

    My Portfolio

    Hello, this is my portfolio. You can also checkout the Blog
    Sidebar
    -
    - + + + + + + + My Portfolio + + + + +
    +
    +

    My Portfolio

    +
    +
    Hello, this is my portfolio. You can also checkout the Blog
    +
    Sidebar
    +
    + + + + + diff --git a/examples/src/blog.js b/examples/src/blog.js index b3ebf85504..0ad4eaf369 100644 --- a/examples/src/blog.js +++ b/examples/src/blog.js @@ -35,43 +35,53 @@ const blogPage = new Page({ title: 'Materialize Web Blog', description: 'This is a custom blog', keywords: 'News sites, personal journals, niche resource hubs, and content creators.', - children: [ - // TODO: use Navigationbar Component - new Container({ - children: new Text('Materialize Web Blog').setTagName('p').addClassname('py-3') - }), + children: new Container({ + children: [ + // TODO: use NavigationBar Component + new Container({ + children: new Text('Materialize Web Blog').setTagName('p').addClassname('py-3') + }).addClassname('wrapper-1'), - new Container({ - children: [ - new AssistChip({ href: './landingpage.html', name: 'Visit Landingpage' }), - new AssistChip({ href: './portfolio.html', name: 'Visit Porfolio' }) - ] - }) - .addClassname('g-2') - .addClassname('p-2') - .addClassname('secondary') - .addClassname('row'), + new Container({ + children: [ + new AssistChip({ href: './landingpage.html', name: 'Visit Landingpage' }) + .addClassname('tooltipped') // tooltip + .setAttribute('data-tooltip', "I'm a toolip") + .setAttribute('data-position', 'bottom'), + new AssistChip({ href: './portfolio.html', name: 'Visit Porfolio' }) + ] + }) + .addClassname('g-2') + .addClassname('p-2') + .addClassname('secondary') + .addClassname('row'), - new Breadcrumb() - .setCrumbs(['Home', 'Articles', 'Crafting Sleek, Responsive Interfaces with MaterializeWeb']) - .addClassname('py-5'), + new Breadcrumb() + .setCrumbs([ + 'Home', + 'Articles', + 'Crafting Sleek, Responsive Interfaces with MaterializeWeb' + ]) + .addClassname('py-5'), - myArticle, - new Container({ - children: - '2 or 3-column masonry grid showing article thumbnails, titles, and publication dates' - }).addClassname('py-5'), - new Button({ children: 'Call to action' }), - new Text( - `This is a test Landingpage. ok it can produce nested html too...
    + myArticle, + new Container({ + children: + '2 or 3-column masonry grid showing article thumbnails, titles, and publication dates' + }).addClassname('py-5'), + new Button({ children: 'Call to action' }), + new Text( + `This is a test Landingpage. ok it can produce nested html too...
    Or you can go to the Portfolio` - ), - new Container({ - children: 'Sidebar or navigation dropdowns ("Tech," "Lifestyle")' - }).addClassname('py-5') - ] + ), + new Container({ + children: 'Sidebar or navigation dropdowns ("Tech," "Lifestyle")' + }).addClassname('py-5') + ] + }) + .setTagName('main') + .addClassname('container') }); - // CSS blogPage .addStyleUrl('/dist/css/materialize.css') @@ -82,6 +92,9 @@ blogPage h3 { font-size: 1.5em; } p, li { font-size: 1.2em; } `); +// Client JS +blogPage.addJavascriptUrl('/dist/js/materialize.js'); +blogPage.addJavascript(`M.AutoInit();`); // Render const html = blogPage.toHTML(); diff --git a/examples/src/landingpage.js b/examples/src/landingpage.js index e287d503b5..d83134ee5b 100644 --- a/examples/src/landingpage.js +++ b/examples/src/landingpage.js @@ -2,24 +2,48 @@ import { Text } from '../../components/atomic/atomic.mjs'; import { Container, Page } from '../../components/atomic/page.mjs'; const landingPage = new Page({ - title: 'My Landingpage', - description: 'Introducing the new Phone', + title: 'T-Shirt Landingpage', + description: 'Introducing the new T-Shirt from Materialize', keywords: 'App launches, SaaS companies, small service businesses, and single-product e-commerce', children: [ - new Container({ children: new Text('Landing Page').setTagName('h1') }), - new Container(``), - new Container({ - children: 'Hero Section', - description: `A bold "Hero" section at the top with a clear headline, followed by social proof (reviews/logos),' + - 'feature blocks, and multiple Calls- to - Action(CTAs) like "Sign Up" or "Buy Now.". - You can also checkout the Blog` - }), + new Container({ children: new Text('a').setTagName('h1') }), + //new Container(``), + new Container({ children: 'T-Shirt', description: `Nothing to see here` }), + // new Container(`A bold "Hero" section at the top with a clear headline, followed by social proof (reviews/logos),
    feature blocks, and multiple Calls- to - Action(CTAs) like "Sign Up" or "Buy Now.
    - You can also checkout the Blog`), + You can also checkout the Blog + klöxökkökökxl +
    + + + + + `), + new Container('Hello, this is my landing page!').addClassname('pt-5') ] -}).addStyleUrl('/dist/css/materialize.css'); +}); +// CSS +landingPage + .addStyleUrl('/dist/css/materialize.css') + .addStyleUrl('https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined'); +// Client JS +landingPage.addJavascriptUrl('/dist/js/materialize.js'); +landingPage.addJavascript(`M.AutoInit();`); const html = landingPage.toHTML(); console.log(html); diff --git a/index.html b/index.html index 9cf2563e1a..df5fd33439 100644 --- a/index.html +++ b/index.html @@ -148,38 +148,16 @@
    Tabs
    //------------------------------------- Example (Nested components to widget) - import { createCounterApp } from '/components/button/examples.iso.js'; + import { createCounterApp, hydrate } from './examples/counter-app.iso.js'; const app = createCounterApp(); const appHtml = app.toHTML(); - console.log(appHtml); - - // html => element const template = document.createElement('template'); template.innerHTML = appHtml; const appElem = template.content.firstElementChild; document.querySelector('.app').replaceChildren(appElem); + hydrate(appElem); - function count(input) { - const value = input.value + input.increment; - return { value } - } - - const state = { value: 12 }; - - const numberEl = appElem.querySelector('.mw-number'); // output - const btns = appElem.querySelectorAll('.btn'); // input - - numberEl.innerHTML = state.value; - - btns[0].addEventListener('click', e => { - state.value = count({ value: state.value , increment: 1 }).value; - numberEl.innerHTML = state.value; - }) - btns[1].addEventListener('click', e => { - state.value = count({ value: state.value , increment: -1 }).value; - numberEl.innerHTML = state.value; - })