diff --git a/.agents/skills/astro-scripts/SKILL.md b/.agents/skills/astro-scripts/SKILL.md index 64a2cbe00..8dd800c96 100644 --- a/.agents/skills/astro-scripts/SKILL.md +++ b/.agents/skills/astro-scripts/SKILL.md @@ -66,6 +66,7 @@ const selector = `#wysiwyg-${id}` - `BEGIN … / END …` comments mark the block in the copied HTML. Use the component name in caps. - A named `init…` function plus the `readyState` guard, never a bare call: the script may be parsed after `DOMContentLoaded` has already fired. +- No `await` (and no `async` function) inside a captured `' }) + + const icon = fixtureEl.querySelector('.nav-overflow-icon')! + expect(icon.querySelector('script')).toBeNull() + expect(icon.querySelector('svg')!.hasAttribute('onload')).toBe(false) + expect(icon.querySelector('svg')!.getAttribute('viewBox')).toEqual('0 0 24 24') + }) + + it('should use a child [data-bs-overflow-icon] element as the icon', () => { + fixtureEl.innerHTML = nav(1000, links(3)).replace('', '') + + new NavOverflow(wrapper(), { moreIcon: '' }) + + const icon = fixtureEl.querySelector('.nav-overflow-icon > svg')! + expect(icon).toHaveClass('custom-icon') + expect(icon.hasAttribute('data-bs-overflow-icon')).toBe(false) + expect(icon.hasAttribute('onclick')).toBe(false) + expect(fixtureEl.querySelectorAll('.custom-icon')).toHaveLength(1) + }) + + it('should map menuPlacement to dropdown classes', () => { + fixtureEl.innerHTML = nav(1000, links(3)) + + new NavOverflow(wrapper(), { menuPlacement: 'top-start' }) + + expect(toggleItem()).toHaveClass('dropup') + expect(fixtureEl.querySelector('.nav-overflow-menu')).not.toHaveClass('dropdown-menu-end') + }) + + it('should pass menuStrategy to the overflow dropdown', () => { + fixtureEl.innerHTML = nav(360, links(6)) + + new NavOverflow(wrapper(), { menuStrategy: 'fixed' }) + + const dropdown = Dropdown.getInstance(fixtureEl.querySelector('.nav-overflow-toggle')!) as Dropdown + expect(dropdown._config.popperConfig).toEqual({ strategy: 'fixed' }) + }) + }) + + describe('collapseBelow', () => { + it('should collapse every item below a pixel value', () => { + fixtureEl.innerHTML = nav(600, links(3)) + + new NavOverflow(wrapper(), { collapseBelow: 700 }) + + expect(hidden()).toHaveLength(3) + }) + + it('should not collapse above a pixel value', () => { + fixtureEl.innerHTML = nav(600, links(3)) + + new NavOverflow(wrapper(), { collapseBelow: 500 }) + + expect(hidden()).toHaveLength(0) + }) + + it('should resolve a breakpoint name from --tblr-breakpoint-{name}', () => { + document.documentElement.style.setProperty('--tblr-breakpoint-md', '768px') + fixtureEl.innerHTML = nav(600, links(3), 'data-bs-collapse-below="md"') + + new NavOverflow(wrapper()) + + expect(hidden()).toHaveLength(3) + document.documentElement.style.removeProperty('--tblr-breakpoint-md') + }) + + it('should keep nav-overflow-keep items when collapsing all', () => { + fixtureEl.innerHTML = nav(600, links(3, { 0: '' })) + + new NavOverflow(wrapper(), { collapseBelow: 700 }) + + expect(hidden()).toHaveLength(2) + }) + }) + + describe('dropdowns', () => { + const DROPDOWN_ITEM = '' + + it('should move an overflowing dropdown into the menu as a nested dropstart', () => { + fixtureEl.innerHTML = nav(260, links(4, { 3: DROPDOWN_ITEM })) + + new NavOverflow(wrapper()) + + const submenu = fixtureEl.querySelector('.nav-overflow-menu > .dropstart')! + const toggle = submenu.firstElementChild as HTMLElement + expect(toggle).toHaveClass('dropdown-item') + expect(toggle).toHaveClass('dropdown-toggle') + expect(toggle.getAttribute('data-bs-toggle')).toEqual('dropdown') + expect(toggle.getAttribute('data-bs-auto-close')).toEqual('outside') + expect(toggle.getAttribute('role')).toEqual('button') + expect(toggle.hasAttribute('href')).toBe(false) + expect(submenu.querySelector('#products-menu')).not.toBeNull() + + const overflow = Dropdown.getInstance(fixtureEl.querySelector('.nav-overflow-toggle')!) as Dropdown + expect(overflow._config.autoClose).toEqual('outside') + }) + + it('should open submenus towards the end when the menu is start-aligned', () => { + fixtureEl.innerHTML = nav(260, links(4, { 3: DROPDOWN_ITEM })) + + new NavOverflow(wrapper(), { menuPlacement: 'bottom-start' }) + + expect(fixtureEl.querySelector('.nav-overflow-menu > .dropend')).not.toBeNull() + expect(fixtureEl.querySelector('.nav-overflow-menu > .dropstart')).toBeNull() + }) + + it('should open the nested dropdown from the overflow menu', () => { + fixtureEl.innerHTML = nav(260, links(4, { 3: DROPDOWN_ITEM })) + + new NavOverflow(wrapper()) + + const toggle = fixtureEl.querySelector('.nav-overflow-menu > .dropstart > .dropdown-toggle')! + toggle.click() + + expect(fixtureEl.querySelector('#products-menu')).toHaveClass('show') + }) + + it('should move the menu back to its nav item on restore', () => { + fixtureEl.innerHTML = nav(260, links(4, { 3: DROPDOWN_ITEM })) + + const navOverflow = new NavOverflow(wrapper()) + wrapper().style.width = '1000px' + navOverflow.update() + + const menu = fixtureEl.querySelector('#products-menu')! + expect(menu.previousElementSibling!.id).toEqual('products') + expect(fixtureEl.querySelector('.nav-overflow-menu .dropstart')).toBeNull() + }) + }) + + describe('proxy links', () => { + const tabs = (): string => links(6, { 5: '' }) + + it('should send a click on a moved plugin link to the original', () => { + fixtureEl.innerHTML = nav(360, tabs()) + + new NavOverflow(wrapper()) + const original = fixtureEl.querySelector('#last')! + const spy = vi.fn() + original.addEventListener('click', spy) + + const cloned = menuItems().at(-1)! + expect(cloned.hasAttribute('data-bs-toggle')).toBe(false) + + cloned.click() + expect(spy).toHaveBeenCalledTimes(1) + }) + + it('should copy the active state to the menu after the click', () => { + fixtureEl.innerHTML = nav(360, tabs()) + + new NavOverflow(wrapper()) + const original = fixtureEl.querySelector('#last')! + original.addEventListener('click', () => original.classList.add('active')) + + const cloned = menuItems().at(-1)! + cloned.click() + + expect(cloned).toHaveClass('active') + }) + + it('should keep plain links as they are', () => { + fixtureEl.innerHTML = nav(360, links(6)) + + new NavOverflow(wrapper()) + + expect(menuItems().at(-1)!.getAttribute('href')).toEqual('#') + }) + }) + + describe('jQueryInterface', () => { + it('should create an instance and call a method', () => { + fixtureEl.innerHTML = nav(1000, links(3)) + + const element = wrapper() + const collection = { each: (callback: (this: HTMLElement) => void) => callback.call(element) } + + NavOverflow.jQueryInterface.call(collection as never, 'update') + + expect(NavOverflow.getInstance(element)).not.toBeNull() + expect(() => NavOverflow.jQueryInterface.call(collection as never, 'nope')).toThrow(TypeError) + }) + }) + + describe('dispose', () => { + it('should restore items and remove the generated toggle', () => { + fixtureEl.innerHTML = nav(360, links(6)) + + const navOverflow = new NavOverflow(wrapper()) + navOverflow.dispose() + + expect(hidden()).toHaveLength(0) + expect(fixtureEl.querySelector('.d-none')).toBeNull() + expect(fixtureEl.querySelector('.nav-overflow-item')).toBeNull() + expect(NavOverflow.getInstance(wrapper())).toBeNull() + }) + + it('should disconnect the ResizeObserver', () => { + fixtureEl.innerHTML = nav(1000, links(3)) + + const navOverflow = new NavOverflow(wrapper()) + const spy = vi.spyOn(navOverflow._resizeObserver!, 'disconnect') + navOverflow.dispose() + + expect(spy).toHaveBeenCalled() + }) + }) + + describe('getOrCreateInstance', () => { + it('should return the existing instance', () => { + fixtureEl.innerHTML = nav(1000, links(3)) + + const navOverflow = new NavOverflow(wrapper()) + + expect(NavOverflow.getOrCreateInstance(wrapper())).toEqual(navOverflow) + }) + }) +}) diff --git a/core/js/tests/unit/number-input.spec.ts b/core/js/tests/unit/number-input.spec.ts new file mode 100644 index 000000000..05f8ce013 --- /dev/null +++ b/core/js/tests/unit/number-input.spec.ts @@ -0,0 +1,473 @@ +import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest' +import { clearFixture, getFixture } from '../helpers/fixture' +import NumberInput from '../../src/number-input' + +const markup = (inputAttributes = 'value="1" min="0" max="5"', wrapperAttributes = ''): string => + [ + `
`, + ' ', + ` `, + ' ', + '
', + ].join('') + +describe('NumberInput', () => { + let fixtureEl: HTMLElement + + beforeAll(() => { + fixtureEl = getFixture() + }) + + beforeEach(() => { + fixtureEl.innerHTML = markup() + }) + + afterEach(() => { + clearFixture() + vi.useRealTimers() + }) + + const numberInput = (): HTMLElement => fixtureEl.querySelector('.number-input')! + const input = (): HTMLInputElement => fixtureEl.querySelector('input')! + const decrementButton = (): HTMLButtonElement => fixtureEl.querySelector('[data-bs-number-input-action="decrement"]')! + const incrementButton = (): HTMLButtonElement => fixtureEl.querySelector('[data-bs-number-input-action="increment"]')! + + const change = (value: string): void => { + input().value = value + input().dispatchEvent(new Event('change', { bubbles: true })) + } + + const keydown = (key: string): KeyboardEvent => { + const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }) + input().dispatchEvent(event) + return event + } + + describe('NAME', () => { + it('should return the plugin name', () => { + expect(NumberInput.NAME).toBe('numberInput') + }) + }) + + describe('Default', () => { + it('should expose the default config', () => { + expect(NumberInput.Default.min).toBeNull() + expect(NumberInput.Default.repeat).toBe(true) + expect(typeof NumberInput.DefaultType.step).toBe('string') + }) + }) + + describe('constructor', () => { + it('should read the bounds from the input attributes', () => { + const instance = new NumberInput(numberInput()) + expect(instance._min).toBe(0) + expect(instance._max).toBe(5) + expect(instance._step).toBe(1) + }) + + it('should let the config override the input attributes', () => { + const instance = new NumberInput(numberInput(), { min: 2, max: 10, step: 2 }) + expect(instance._min).toBe(2) + expect(instance._max).toBe(10) + expect(instance._step).toBe(2) + expect(input().value).toBe('2') + }) + + it('should read the config from data attributes', () => { + fixtureEl.innerHTML = markup('value="1"', 'data-bs-min="1" data-bs-max="3" data-bs-step="0.5"') + const instance = new NumberInput(numberInput()) + expect(instance._min).toBe(1) + expect(instance._max).toBe(3) + expect(instance._step).toBe(0.5) + }) + + it('should have no bounds when neither attributes nor config set them', () => { + fixtureEl.innerHTML = markup('value="1"') + const instance = new NumberInput(numberInput()) + expect(instance._min).toBe(Number.NEGATIVE_INFINITY) + expect(instance._max).toBe(Number.POSITIVE_INFINITY) + }) + + it('should fall back to a step of 1 for a zero or negative step', () => { + fixtureEl.innerHTML = markup('value="1" step="0"') + expect(new NumberInput(numberInput())._step).toBe(1) + }) + + it('should clamp the initial value', () => { + fixtureEl.innerHTML = markup('value="9" min="0" max="5"') + new NumberInput(numberInput()) + expect(input().value).toBe('5') + }) + + it('should give a text input the spinbutton role and a numeric inputmode', () => { + new NumberInput(numberInput()) + expect(input().getAttribute('role')).toBe('spinbutton') + expect(input().getAttribute('inputmode')).toBe('numeric') + expect(input().getAttribute('aria-valuemin')).toBe('0') + expect(input().getAttribute('aria-valuemax')).toBe('5') + expect(input().getAttribute('aria-valuenow')).toBe('1') + }) + + it('should use the decimal inputmode for a fractional step', () => { + fixtureEl.innerHTML = markup('value="1" step="0.25"') + new NumberInput(numberInput()) + expect(input().getAttribute('inputmode')).toBe('decimal') + }) + + it('should leave a number input alone', () => { + fixtureEl.innerHTML = markup('type="number" value="1" min="0" max="5"').replace('type="text"', '') + new NumberInput(numberInput()) + expect(input().hasAttribute('role')).toBe(false) + expect(input().hasAttribute('aria-valuenow')).toBe(false) + }) + + it('should do nothing without an input', () => { + fixtureEl.innerHTML = '
' + const instance = new NumberInput(numberInput()) + expect(instance._input).toBeNull() + expect(instance.getValue()).toBeNull() + }) + + it('should disable the buttons of a disabled input', () => { + fixtureEl.innerHTML = markup('value="1" disabled') + new NumberInput(numberInput()) + expect(decrementButton().disabled).toBe(true) + expect(incrementButton().disabled).toBe(true) + }) + }) + + describe('buttons', () => { + it('should step the value on click', () => { + new NumberInput(numberInput()) + incrementButton().click() + expect(input().value).toBe('2') + decrementButton().click() + decrementButton().click() + expect(input().value).toBe('0') + }) + + it('should disable the button at a bound', () => { + fixtureEl.innerHTML = markup('value="4" min="0" max="5"') + new NumberInput(numberInput()) + expect(incrementButton().disabled).toBe(false) + incrementButton().click() + expect(input().value).toBe('5') + expect(incrementButton().disabled).toBe(true) + expect(decrementButton().disabled).toBe(false) + }) + + it('should never leave the bounds', () => { + const instance = new NumberInput(numberInput(), { max: 2 }) + instance.increment() + instance.increment() + instance.increment() + expect(input().value).toBe('2') + }) + + it('should keep stepping while a button is held', () => { + vi.useFakeTimers() + new NumberInput(numberInput(), { max: 100 }) + + incrementButton().dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, pointerType: 'mouse' })) + vi.advanceTimersByTime(400) + expect(input().value).toBe('2') + vi.advanceTimersByTime(80 * 3) + expect(input().value).toBe('5') + + incrementButton().dispatchEvent(new PointerEvent('pointerup', { bubbles: true })) + vi.advanceTimersByTime(1000) + expect(input().value).toBe('5') + + // The click that ends the hold is not one more step + incrementButton().click() + expect(input().value).toBe('5') + }) + + it('should stop a hold at a bound', () => { + vi.useFakeTimers() + const instance = new NumberInput(numberInput(), { max: 3 }) + + incrementButton().dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, pointerType: 'mouse' })) + vi.advanceTimersByTime(400 + 80 * 5) + expect(input().value).toBe('3') + expect(instance._repeatTimer).toBeNull() + }) + + it('should not repeat when the option is off', () => { + vi.useFakeTimers() + new NumberInput(numberInput(), { repeat: false, max: 100 }) + + incrementButton().dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, pointerType: 'mouse' })) + vi.advanceTimersByTime(2000) + expect(input().value).toBe('1') + }) + }) + + describe('keyboard', () => { + it('should step with the arrow keys and jump with Home and End', () => { + new NumberInput(numberInput()) + expect(keydown('ArrowUp').defaultPrevented).toBe(true) + expect(input().value).toBe('2') + keydown('ArrowDown') + expect(input().value).toBe('1') + keydown('End') + expect(input().value).toBe('5') + keydown('Home') + expect(input().value).toBe('0') + }) + + it('should ignore Home and End without a bound', () => { + fixtureEl.innerHTML = markup('value="1"') + new NumberInput(numberInput()) + expect(keydown('End').defaultPrevented).toBe(false) + expect(input().value).toBe('1') + }) + + it('should leave other keys alone', () => { + new NumberInput(numberInput()) + expect(keydown('a').defaultPrevented).toBe(false) + }) + }) + + describe('typed value', () => { + it('should clamp a typed value on change', () => { + new NumberInput(numberInput()) + change('42') + expect(input().value).toBe('5') + expect(incrementButton().disabled).toBe(true) + }) + + it('should clamp on blur as well', () => { + new NumberInput(numberInput()) + input().value = '125' + input().dispatchEvent(new Event('blur')) + expect(input().value).toBe('5') + }) + + it('should fire nothing when an untouched field is left', () => { + new NumberInput(numberInput()) + const spy = vi.fn() + input().addEventListener('change', spy) + input().dispatchEvent(new Event('blur')) + expect(spy).not.toHaveBeenCalled() + }) + + it('should restore the previous value after a non-numeric edit', () => { + new NumberInput(numberInput()) + change('3') + change('abc') + expect(input().value).toBe('3') + }) + + it('should accept a decimal comma', () => { + fixtureEl.innerHTML = markup('value="1" step="0.5"') + new NumberInput(numberInput()) + change('2,5') + expect(input().value).toBe('2.5') + }) + }) + + describe('character filter', () => { + const beforeInput = (data: string, inputType = 'insertText'): InputEvent => { + const event = new InputEvent('beforeinput', { data, inputType, bubbles: true, cancelable: true }) + input().dispatchEvent(event) + return event + } + + it('should drop a typed letter and keep a digit', () => { + new NumberInput(numberInput()) + expect(beforeInput('a').defaultPrevented).toBe(true) + expect(beforeInput('7').defaultPrevented).toBe(false) + expect(beforeInput('.').defaultPrevented).toBe(true) + expect(beforeInput('-').defaultPrevented).toBe(true) + }) + + it('should allow the sign and the separator when the bounds and step need them', () => { + fixtureEl.innerHTML = markup('value="0" min="-5" step="0.5"') + new NumberInput(numberInput()) + expect(beforeInput('-').defaultPrevented).toBe(false) + expect(beforeInput('.').defaultPrevented).toBe(false) + expect(beforeInput(',').defaultPrevented).toBe(false) + expect(beforeInput('e').defaultPrevented).toBe(true) + }) + + it('should keep only the digits of a pasted text', () => { + new NumberInput(numberInput()) + input().value = '' + input().setSelectionRange(0, 0) + expect(beforeInput('4 pcs', 'insertFromPaste').defaultPrevented).toBe(true) + expect(input().value).toBe('4') + }) + + it('should leave deletions alone', () => { + new NumberInput(numberInput()) + expect(beforeInput('', 'deleteContentBackward').defaultPrevented).toBe(false) + }) + }) + + describe('precision', () => { + it('should round to the precision of the step', () => { + fixtureEl.innerHTML = markup('value="0.1" step="0.1"') + const instance = new NumberInput(numberInput()) + instance.increment() + instance.increment() + expect(input().value).toBe('0.3') + expect(instance.getValue()).toBe(0.3) + }) + + it('should step an empty field from the lower bound', () => { + fixtureEl.innerHTML = markup('value="" min="2" max="9"') + const instance = new NumberInput(numberInput()) + expect(instance.getValue()).toBeNull() + expect(input().hasAttribute('aria-valuenow')).toBe(false) + instance.increment() + expect(input().value).toBe('3') + }) + }) + + describe('events', () => { + it('should fire change.bs.numberInput with the values', () => { + const instance = new NumberInput(numberInput()) + const spy = vi.fn() + numberInput().addEventListener('change.bs.numberInput', spy) + + instance.increment() + expect(spy).toHaveBeenCalledTimes(1) + const event = spy.mock.calls[0][0] as Event & { value: number; previousValue: number } + expect(event.value).toBe(2) + expect(event.previousValue).toBe(1) + }) + + it('should fire input and change on the field', () => { + const instance = new NumberInput(numberInput()) + const onInput = vi.fn() + const onChange = vi.fn() + input().addEventListener('input', onInput) + input().addEventListener('change', onChange) + + instance.increment() + expect(onInput).toHaveBeenCalledTimes(1) + expect(onChange).toHaveBeenCalledTimes(1) + }) + + it('should not fire when the value does not change', () => { + const instance = new NumberInput(numberInput()) + const spy = vi.fn() + numberInput().addEventListener('change.bs.numberInput', spy) + + instance.setValue(1) + instance.decrement() + instance.decrement() + expect(spy).toHaveBeenCalledTimes(1) + }) + }) + + describe('setValue', () => { + it('should set and clamp the value', () => { + const instance = new NumberInput(numberInput()) + instance.setValue(3) + expect(input().value).toBe('3') + instance.setValue('99') + expect(input().value).toBe('5') + instance.setValue('nope') + expect(input().value).toBe('5') + }) + }) + + describe('disabled', () => { + it('should not step a disabled or readonly input', () => { + fixtureEl.innerHTML = markup('value="1" readonly') + const instance = new NumberInput(numberInput()) + instance.increment() + expect(input().value).toBe('1') + expect(incrementButton().disabled).toBe(true) + }) + }) + + describe('dispose', () => { + it('should remove the listeners and the instance', () => { + const instance = new NumberInput(numberInput()) + instance.dispose() + incrementButton().click() + expect(input().value).toBe('1') + expect(NumberInput.getInstance(numberInput())).toBeNull() + }) + }) + + describe('bare input', () => { + const bare = (attributes = 'value="1" min="0" max="5"'): HTMLInputElement => { + fixtureEl.innerHTML = `
` + return fixtureEl.querySelector('input')! + } + + it('should wrap the input and render the two buttons', () => { + const el = bare() + new NumberInput(el) + + const wrapper = el.parentElement! + expect(wrapper.classList.contains('number-input')).toBe(true) + expect(wrapper.parentElement!.tagName).toBe('FORM') + expect([...wrapper.children].map((child) => child.tagName)).toEqual(['BUTTON', 'INPUT', 'BUTTON']) + expect(decrementButton().getAttribute('aria-label')).toBe('Decrease') + expect(incrementButton().getAttribute('aria-label')).toBe('Increase') + expect(incrementButton().type).toBe('button') + expect(incrementButton().innerHTML).toBe('') + }) + + it('should step through the rendered buttons', () => { + const el = bare() + new NumberInput(el) + incrementButton().click() + expect(el.value).toBe('2') + }) + + it('should read the config from the input and label the buttons', () => { + const el = bare('value="1" data-bs-max="2" data-bs-increment-label="More" data-bs-decrement-label="Fewer"') + new NumberInput(el) + expect(incrementButton().getAttribute('aria-label')).toBe('More') + expect(decrementButton().getAttribute('aria-label')).toBe('Fewer') + incrementButton().click() + incrementButton().click() + expect(el.value).toBe('2') + }) + + it('should map the size of the input to the box', () => { + fixtureEl.innerHTML = '' + const el = fixtureEl.querySelector('input')! + new NumberInput(el) + expect(el.parentElement!.classList.contains('number-input-sm')).toBe(true) + }) + + it('should fire change.bs.numberInput on the input', () => { + const el = bare() + const instance = new NumberInput(el) + const spy = vi.fn() + el.addEventListener('change.bs.numberInput', spy) + instance.increment() + expect(spy).toHaveBeenCalledTimes(1) + }) + + it('should unwrap the input on dispose', () => { + const el = bare() + new NumberInput(el).dispose() + expect(el.parentElement!.tagName).toBe('FORM') + expect(fixtureEl.querySelector('.number-input')).toBeNull() + expect(fixtureEl.querySelectorAll('button')).toHaveLength(0) + }) + }) + + describe('data-api', () => { + it('should initialise on getOrCreateInstance', () => { + const instance = NumberInput.getOrCreateInstance(numberInput()) + expect(instance).toBeInstanceOf(NumberInput) + expect(NumberInput.getOrCreateInstance(numberInput())).toBe(instance) + }) + + it('should accept the data-tblr-* action attributes', () => { + fixtureEl.innerHTML = markup().replaceAll('data-bs-number-input-action', 'data-tblr-number-input-action') + new NumberInput(numberInput()) + fixtureEl.querySelector('[data-tblr-number-input-action="increment"]')!.click() + expect(input().value).toBe('2') + }) + }) +}) diff --git a/core/js/tests/unit/vector-map.spec.ts b/core/js/tests/unit/vector-map.spec.ts new file mode 100644 index 000000000..181de4359 --- /dev/null +++ b/core/js/tests/unit/vector-map.spec.ts @@ -0,0 +1,865 @@ +import { describe, it, expect, beforeAll, afterEach, vi } from 'vitest' +import { clearFixture, getFixture } from '../helpers/fixture' +import VectorMap from '../../src/vector-map' +import type { VectorMapData } from '../../src/vector-map' +import { world } from '../../maps/world' + +const squares: VectorMapData = { + width: 30, + height: 10, + projection: { type: 'miller', scale: 1, translate: [0, 0] }, + regions: { + AA: { name: 'First', path: 'M0 0l10 0 0 10-10 0z' }, + BB: { name: 'Second', path: 'M10 0l10 0 0 10-10 0z' }, + CC: { name: 'Third', path: 'M20 0l10 0 0 10-10 0z' }, + }, +} + +describe('VectorMap', () => { + let fixtureEl: HTMLElement + + beforeAll(() => { + fixtureEl = getFixture() + }) + + afterEach(() => { + clearFixture() + VectorMap.maps = {} + delete window.tablerVectorMaps + }) + + const el = (): HTMLElement => fixtureEl.querySelector('.vector-map')! + const region = (code: string): SVGGeometryElement => el().querySelector(`[data-region="${code}"]`)! + const ratio = (code: string): string => region(code).style.getPropertyValue('--tblr-vector-map-value') + + describe('NAME', () => { + it('should return plugin name', () => { + expect(VectorMap.NAME).toBe('vector-map') + }) + }) + + describe('constructor', () => { + it('should render one path per region of the map', () => { + fixtureEl.innerHTML = '
' + VectorMap.addMap('squares', squares) + + const spy = vi.fn() + el().addEventListener('rendered.bs.vector-map', spy) + new VectorMap(el()) + + const svg = el().querySelector('svg')! + expect(svg.getAttribute('viewBox')).toBe('0 0 30 10') + expect(svg.querySelectorAll('path.vector-map-region').length).toBe(3) + expect(region('BB').getAttribute('d')).toBe(squares.regions.BB.path) + expect(spy).toHaveBeenCalledTimes(1) + }) + + it('should take the map as an object', () => { + fixtureEl.innerHTML = '
' + + new VectorMap(el(), { map: squares }) + + expect(el().querySelectorAll('path').length).toBe(3) + }) + + it('should find a map loaded with a script tag', () => { + fixtureEl.innerHTML = '
' + window.tablerVectorMaps = { squares } + + new VectorMap(el()) + + expect(el().querySelectorAll('path').length).toBe(3) + }) + + it('should throw when the map is not loaded', () => { + fixtureEl.innerHTML = '
' + + expect(() => new VectorMap(el())).toThrowError('VECTOR-MAP: Map "nowhere" is not loaded.') + }) + + it('should name the map for assistive technology when it has a label', () => { + fixtureEl.innerHTML = '
' + + new VectorMap(el(), { map: squares }) + + const svg = el().querySelector('svg')! + expect(svg.getAttribute('role')).toBe('img') + expect(svg.getAttribute('aria-label')).toBe('Sales by country') + expect(svg.hasAttribute('aria-hidden')).toBe(false) + }) + + it('should hide the map from assistive technology without a label', () => { + fixtureEl.innerHTML = '
' + + new VectorMap(el(), { map: squares }) + + expect(el().querySelector('svg')!.getAttribute('aria-hidden')).toBe('true') + }) + }) + + describe('values', () => { + it('should shade regions by their place between the lowest and highest value', () => { + fixtureEl.innerHTML = `
` + + new VectorMap(el(), { map: squares }) + + expect(region('AA').getAttribute('data-value')).toBe('10') + expect(ratio('AA')).toBe('0') + expect(ratio('BB')).toBe('0.25') + expect(ratio('CC')).toBe('1') + }) + + it('should leave a region without a value untouched', () => { + fixtureEl.innerHTML = '
' + + new VectorMap(el(), { map: squares, values: { AA: 1, ZZ: 5 } }) + + expect(region('BB').hasAttribute('data-value')).toBe(false) + expect(ratio('BB')).toBe('') + }) + + it('should use the forced range and clamp values outside of it', () => { + fixtureEl.innerHTML = '
' + + new VectorMap(el(), { map: squares, values: { AA: 25, BB: 150, CC: -10 } }) + + expect(ratio('AA')).toBe('0.25') + expect(ratio('BB')).toBe('1') + expect(ratio('CC')).toBe('0') + }) + + it('should give the full colour when all values are equal', () => { + fixtureEl.innerHTML = '
' + + new VectorMap(el(), { map: squares, values: { AA: 7, BB: 7 } }) + + expect(ratio('AA')).toBe('1') + expect(ratio('BB')).toBe('1') + }) + + it('should ignore values that are not a number', () => { + fixtureEl.innerHTML = `
` + + new VectorMap(el(), { map: squares }) + + expect(region('AA').hasAttribute('data-value')).toBe(true) + expect(region('BB').hasAttribute('data-value')).toBe(false) + expect(region('CC').hasAttribute('data-value')).toBe(false) + }) + }) + + describe('update', () => { + it('should replace the values without drawing the map again', () => { + fixtureEl.innerHTML = `
` + + const map = new VectorMap(el(), { map: squares }) + const svg = el().querySelector('svg') + const spy = vi.fn() + el().addEventListener('updated.bs.vector-map', spy) + + map.update({ BB: 1, CC: 3 }) + + expect(el().querySelector('svg')).toBe(svg) + expect(region('AA').hasAttribute('data-value')).toBe(false) + expect(ratio('AA')).toBe('') + expect(ratio('BB')).toBe('0') + expect(ratio('CC')).toBe('1') + expect(spy).toHaveBeenCalledTimes(1) + }) + }) + + describe('colors', () => { + const colorVar = (index: number): string => el().style.getPropertyValue(`--tblr-vector-map-color-${index}`) + + it('should hand a scale of colors to the stylesheet', () => { + fixtureEl.innerHTML = `
` + + new VectorMap(el(), { map: squares, values: { AA: 1, BB: 2, CC: 3 } }) + + expect(el().classList.contains('vector-map-colors')).toBe(true) + expect(el().style.getPropertyValue('--tblr-vector-map-colors')).toBe('3') + expect(colorVar(1)).toBe('red') + expect(colorVar(2)).toBe('yellow') + expect(colorVar(3)).toBe('green') + expect(colorVar(4)).toBe('') + // The share of a region stays the same, only the stylesheet reads it differently + expect(ratio('BB')).toBe('0.5') + }) + + it('should keep the single color with fewer than two colors', () => { + fixtureEl.innerHTML = '
' + + new VectorMap(el(), { map: squares, colors: ['red'] }) + + expect(el().classList.contains('vector-map-colors')).toBe(false) + expect(el().style.getPropertyValue('--tblr-vector-map-colors')).toBe('') + expect(colorVar(1)).toBe('') + }) + + it('should use the first five colors', () => { + fixtureEl.innerHTML = '
' + + new VectorMap(el(), { map: squares, colors: ['#111', '#222', '#333', '#444', '#555', '#666'] }) + + expect(el().style.getPropertyValue('--tblr-vector-map-colors')).toBe('5') + expect(colorVar(5)).toBe('#555') + expect(colorVar(6)).toBe('') + }) + + it('should drop the scale when render() no longer has one, and on dispose', () => { + fixtureEl.innerHTML = `
` + + const map = new VectorMap(el(), { map: squares }) + el().removeAttribute('data-bs-colors') + map.render() + + expect(el().classList.contains('vector-map-colors')).toBe(false) + expect(colorVar(1)).toBe('') + + el().setAttribute('data-bs-colors', '["red", "green"]') + map.render() + expect(colorVar(2)).toBe('green') + + map.dispose() + expect(el().classList.contains('vector-map-colors')).toBe(false) + expect(colorVar(2)).toBe('') + }) + }) + + describe('click', () => { + it('should fire regionclick with the code, the name and the value of the region', () => { + fixtureEl.innerHTML = '
' + new VectorMap(el(), { map: squares, values: { AA: 5 } }) + + const spy = vi.fn() + el().addEventListener('regionclick.bs.vector-map', spy) + region('AA').dispatchEvent(new MouseEvent('click', { bubbles: true })) + region('BB').dispatchEvent(new MouseEvent('click', { bubbles: true })) + + expect(spy).toHaveBeenCalledTimes(2) + expect(spy.mock.calls[0][0]).toMatchObject({ code: 'AA', name: 'First', value: 5, relatedTarget: region('AA') }) + expect(spy.mock.calls[1][0]).toMatchObject({ code: 'BB', name: 'Second' }) + expect('value' in spy.mock.calls[1][0]).toBe(false) + }) + + it('should fire markerclick with the marker, and no regionclick', () => { + fixtureEl.innerHTML = '
' + new VectorMap(el(), { + map: squares, + markers: [ + { name: 'One', lat: 0, lng: 0 }, + { name: 'Two', lat: 1, lng: 2 }, + ], + }) + + const markerSpy = vi.fn() + const regionSpy = vi.fn() + el().addEventListener('markerclick.bs.vector-map', markerSpy) + el().addEventListener('regionclick.bs.vector-map', regionSpy) + el() + .querySelectorAll('.vector-map-marker-dot')[1] + .dispatchEvent(new MouseEvent('click', { bubbles: true })) + + expect(markerSpy.mock.calls[0][0]).toMatchObject({ name: 'Two', lat: 1, lng: 2 }) + expect(regionSpy).not.toHaveBeenCalled() + }) + + it('should not take the click that ends a drag for a click on a region', () => { + fixtureEl.innerHTML = '
' + new VectorMap(el(), { map: squares, zoomMax: 2 }) + el().dispatchEvent(new WheelEvent('wheel', { bubbles: true, cancelable: true, deltaY: -5000 })) + + const spy = vi.fn() + el().addEventListener('regionclick.bs.vector-map', spy) + const pointer = (type: string, x: number): void => { + region('BB').dispatchEvent(new PointerEvent(type, { bubbles: true, pointerId: 1, pointerType: 'mouse', button: 0, clientX: x, clientY: 50 })) + } + + pointer('pointerdown', 100) + pointer('pointermove', 60) + pointer('pointerup', 60) + region('BB').dispatchEvent(new MouseEvent('click', { bubbles: true })) + expect(spy).not.toHaveBeenCalled() + + // A press that moves by a pixel or two is still a click + const viewBox = el().querySelector('svg')!.getAttribute('viewBox') + pointer('pointerdown', 100) + pointer('pointermove', 102) + pointer('pointerup', 102) + region('BB').dispatchEvent(new MouseEvent('click', { bubbles: true })) + expect(spy).toHaveBeenCalledTimes(1) + expect(el().querySelector('svg')!.getAttribute('viewBox')).toBe(viewBox) + }) + }) + + describe('select', () => { + const click = (code: string): void => { + region(code).dispatchEvent(new MouseEvent('click', { bubbles: true })) + } + + const isSelected = (code: string): boolean => region(code).classList.contains('vector-map-region-selected') + + it('should not select anything by default', () => { + fixtureEl.innerHTML = '
' + const map = new VectorMap(el(), { map: squares, selected: ['AA'] }) + + click('BB') + + expect(map.getSelected()).toEqual([]) + expect(isSelected('AA')).toBe(false) + expect(el().classList.contains('vector-map-selectable')).toBe(false) + }) + + it('should select one region at a time, and take it out on a second click', () => { + fixtureEl.innerHTML = '
' + const map = new VectorMap(el(), { map: squares }) + const spy = vi.fn() + el().addEventListener('selected.bs.vector-map', spy) + + expect(el().classList.contains('vector-map-selectable')).toBe(true) + + click('AA') + expect(map.getSelected()).toEqual(['AA']) + expect(isSelected('AA')).toBe(true) + expect(spy.mock.calls[0][0]).toMatchObject({ selected: ['AA'], code: 'AA' }) + + click('BB') + expect(map.getSelected()).toEqual(['BB']) + expect(isSelected('AA')).toBe(false) + + click('BB') + expect(map.getSelected()).toEqual([]) + expect(spy).toHaveBeenCalledTimes(3) + }) + + it('should select several regions', () => { + fixtureEl.innerHTML = `
` + const map = new VectorMap(el(), { map: squares }) + + expect(map.getSelected()).toEqual(['CC']) + + click('AA') + expect(map.getSelected()).toEqual(['CC', 'AA']) + + click('CC') + expect(map.getSelected()).toEqual(['AA']) + expect(isSelected('CC')).toBe(false) + }) + + it('should keep the first of several codes when one region can be selected', () => { + fixtureEl.innerHTML = '
' + const map = new VectorMap(el(), { map: squares, select: 'single', selected: ['BB', 'CC'] }) + + expect(map.getSelected()).toEqual(['BB']) + }) + + it('should not select when regionclick is prevented', () => { + fixtureEl.innerHTML = '
' + const map = new VectorMap(el(), { map: squares }) + el().addEventListener('regionclick.bs.vector-map', (event) => { + event.preventDefault() + }) + + click('AA') + + expect(map.getSelected()).toEqual([]) + }) + + it('should set the selection from code', () => { + fixtureEl.innerHTML = '
' + const map = new VectorMap(el(), { map: squares }) + const spy = vi.fn() + el().addEventListener('selected.bs.vector-map', spy) + + map.setSelected(['AA', 'CC', 'nowhere']) + expect(map.getSelected()).toEqual(['AA', 'CC']) + expect(spy.mock.calls[0][0]).toMatchObject({ selected: ['AA', 'CC'] }) + + map.setSelected([]) + expect(isSelected('AA')).toBe(false) + }) + + it('should draw a selected shape after the other shapes, under the markers', () => { + fixtureEl.innerHTML = '
' + new VectorMap(el(), { map: squares, markers: [{ name: 'One', lat: 0, lng: 0 }] }) + + click('AA') + + const children = [...el().querySelector('svg')!.children] + expect(children.indexOf(region('AA'))).toBeGreaterThan(children.indexOf(region('CC'))) + expect(children.indexOf(region('AA'))).toBeLessThan(children.indexOf(el().querySelector('.vector-map-marker')!)) + }) + + it('should keep the selection over update() and render()', () => { + fixtureEl.innerHTML = '
' + const map = new VectorMap(el(), { map: squares }) + + click('BB') + map.update({ AA: 1 }) + map.render() + + expect(map.getSelected()).toEqual(['BB']) + expect(isSelected('BB')).toBe(true) + }) + }) + + describe('legend', () => { + const legend = (): HTMLElement | null => el().querySelector('.vector-map-legend') + const labels = (): string[] => [...el().querySelectorAll('.vector-map-legend-label')].map((label) => label.textContent ?? '') + const steps = (): HTMLElement[] => [...el().querySelectorAll('.vector-map-legend-step')] + + it('should not be there by default', () => { + fixtureEl.innerHTML = '
' + + new VectorMap(el(), { map: squares, values: { AA: 1, BB: 2 } }) + + expect(legend()).toBeNull() + }) + + it('should show the lowest and the highest value around a bar that runs over the whole scale', () => { + fixtureEl.innerHTML = '
' + + new VectorMap(el(), { map: squares, values: { AA: 10, BB: 20, CC: 1500 } }) + + expect(labels()).toEqual(['10', (1500).toLocaleString()]) + expect(steps().length).toBeGreaterThan(10) + expect(steps()[0].style.getPropertyValue('--tblr-vector-map-value')).toBe('0') + expect(steps().at(-1)!.style.getPropertyValue('--tblr-vector-map-value')).toBe('1') + expect(el().querySelector('.vector-map-legend-scale')!.getAttribute('aria-hidden')).toBe('true') + // The legend comes after the drawing + expect(el().lastElementChild).toBe(legend()) + }) + + it('should use the forced range', () => { + fixtureEl.innerHTML = '
' + + new VectorMap(el(), { map: squares, values: { AA: 25 } }) + + expect(labels()).toEqual(['0', '100']) + }) + + it('should format the labels with a function', () => { + fixtureEl.innerHTML = '
' + + new VectorMap(el(), { map: squares, values: { AA: 5, BB: 80 }, legend: (value) => `${value}%` }) + + expect(labels()).toEqual(['5%', '80%']) + }) + + it('should follow the values on update(), with one legend at all times', () => { + fixtureEl.innerHTML = '
' + + const map = new VectorMap(el(), { map: squares, values: { AA: 1, BB: 2 } }) + map.update({ AA: 3, CC: 9 }) + + expect(labels()).toEqual(['3', '9']) + expect(el().querySelectorAll('.vector-map-legend').length).toBe(1) + }) + + it('should stay away when the map has no values, and go on dispose', () => { + fixtureEl.innerHTML = '
' + + const map = new VectorMap(el(), { map: squares }) + expect(legend()).toBeNull() + + map.update({ AA: 1 }) + expect(legend()).not.toBeNull() + + map.dispose() + expect(legend()).toBeNull() + }) + }) + + describe('points', () => { + const islands: VectorMapData = { + ...squares, + regions: { ZZ: { name: 'Island', path: 'M15 5h0', point: true }, ...squares.regions }, + } + + it('should draw a region that is a point as a dot with a ring, on top of the shapes', () => { + fixtureEl.innerHTML = '
' + + new VectorMap(el(), { map: islands }) + + const point = region('ZZ') + expect(point.tagName).toBe('g') + expect(point.classList.contains('vector-map-region-point')).toBe(true) + expect(point.querySelector('.vector-map-region-point-halo')!.getAttribute('d')).toBe('M15 5h0') + expect(point.querySelector('.vector-map-region-point-dot')!.getAttribute('d')).toBe('M15 5h0') + expect(el().querySelector('svg')!.lastElementChild).toBe(point) + }) + + it('should give a point a value and a tooltip like any region', () => { + fixtureEl.innerHTML = '
' + + new VectorMap(el(), { map: islands, values: { AA: 1, ZZ: 3 } }) + + expect(region('ZZ').getAttribute('data-value')).toBe('3') + expect(ratio('ZZ')).toBe('1') + + region('ZZ') + .querySelector('.vector-map-region-point-dot')! + .dispatchEvent(new PointerEvent('pointermove', { bubbles: true })) + expect(el().querySelector('.vector-map-tooltip')!.textContent).toBe('Island: 3') + }) + }) + + describe('markers', () => { + it('should place a marker by its coordinates', () => { + fixtureEl.innerHTML = `
` + + new VectorMap(el(), { map: world }) + + const marker = el().querySelector('.vector-map-marker')! + expect(marker.getAttribute('data-marker')).toBe('Null Island') + expect(marker.querySelector('.vector-map-marker-halo')!.getAttribute('d')).toBe('M500 316h0') + expect(marker.querySelector('.vector-map-marker-dot')!.getAttribute('d')).toBe('M500 316h0') + }) + + it('should put a marker inside the country it belongs to', () => { + fixtureEl.innerHTML = '
' + + new VectorMap(el(), { map: world, markers: [{ name: 'Warsaw', lat: 52.23, lng: 21.01 }] }) + + const [x, y] = el().querySelector('.vector-map-marker-dot')!.getAttribute('d')!.slice(1, -2).split(' ').map(Number) + const svg = el().querySelector('svg')! + const point = svg.createSVGPoint() + point.x = x + point.y = y + expect(region('PL').isPointInFill(point)).toBe(true) + expect(region('DE').isPointInFill(point)).toBe(false) + }) + + it('should leave out a marker without real coordinates', () => { + fixtureEl.innerHTML = `
` + + new VectorMap(el(), { map: squares }) + + expect(el().querySelectorAll('.vector-map-marker').length).toBe(1) + }) + + it('should draw a curve between two markers, under the markers', () => { + fixtureEl.innerHTML = '
' + + new VectorMap(el(), { + map: world, + markers: [ + { name: 'A', lat: 0, lng: 0 }, + { name: 'B', lat: 0, lng: 90 }, + ], + lines: [{ from: 'A', to: 'B' }], + }) + + const line = el().querySelector('.vector-map-line')! + expect(line.getAttribute('d')).toBe('M500 316Q625 266 750 316') + expect(line.compareDocumentPosition(el().querySelector('.vector-map-marker')!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + }) + + it('should skip a line to a marker that does not exist', () => { + fixtureEl.innerHTML = '
' + + new VectorMap(el(), { map: squares, markers: [{ name: 'A', lat: 0, lng: 0 }], lines: [{ from: 'A', to: 'Nowhere' }] }) + + expect(el().querySelector('.vector-map-line')).toBeNull() + }) + }) + + describe('tooltip', () => { + const tooltip = (): HTMLElement | null => el().querySelector('.vector-map-tooltip') + const moveOver = (target: Element): void => { + target.dispatchEvent(new PointerEvent('pointermove', { bubbles: true, clientX: 10, clientY: 10 })) + } + + it('should show the name of the region under the pointer, with its value', () => { + fixtureEl.innerHTML = '
' + + new VectorMap(el(), { map: squares, values: { AA: 5 } }) + expect(tooltip()).toBeNull() + + moveOver(region('AA')) + expect(tooltip()!.textContent).toBe('First: 5') + expect(tooltip()!.hidden).toBe(false) + + moveOver(region('BB')) + expect(tooltip()!.textContent).toBe('Second') + expect(el().querySelectorAll('.vector-map-tooltip').length).toBe(1) + }) + + it('should show the name of a marker', () => { + fixtureEl.innerHTML = '
' + + new VectorMap(el(), { map: squares, markers: [{ name: 'Capital', lat: 0, lng: 0 }] }) + moveOver(el().querySelector('.vector-map-marker-dot')!) + + expect(tooltip()!.textContent).toBe('Capital') + }) + + it('should hide when the pointer leaves the map', () => { + fixtureEl.innerHTML = '
' + + new VectorMap(el(), { map: squares }) + moveOver(region('AA')) + el().dispatchEvent(new PointerEvent('pointerleave')) + + expect(tooltip()!.hidden).toBe(true) + }) + + it('should not show when turned off', () => { + fixtureEl.innerHTML = '
' + + new VectorMap(el(), { map: squares }) + moveOver(region('AA')) + + expect(tooltip()).toBeNull() + }) + + it('should take the text from a function, as plain text', () => { + fixtureEl.innerHTML = '
' + + new VectorMap(el(), { + map: squares, + values: { AA: 5 }, + tooltip: (item) => (item.type === 'region' && item.value !== undefined ? `${item.code} ${item.value} %` : ''), + }) + + moveOver(region('AA')) + expect(tooltip()!.textContent).toBe('AA 5 %') + expect(tooltip()!.querySelector('b')).toBeNull() + + moveOver(region('BB')) + expect(tooltip()!.hidden).toBe(true) + }) + }) + + describe('zoom', () => { + const svg = (): SVGSVGElement => el().querySelector('.vector-map-svg')! + const viewBox = (): number[] => svg().getAttribute('viewBox')!.split(' ').map(Number) + const button = (action: string): HTMLButtonElement => el().querySelector(`[data-bs-vector-map-action="${action}"]`)! + const pointer = (type: string, x: number, y: number, pointerId = 1): void => { + region('BB').dispatchEvent(new PointerEvent(type, { bubbles: true, pointerId, pointerType: 'mouse', button: 0, clientX: x, clientY: y })) + } + + const wheel = (deltaY: number, target: Element = region('BB')): WheelEvent => { + const rect = target.getBoundingClientRect() + const event = new WheelEvent('wheel', { bubbles: true, cancelable: true, deltaY, clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2 }) + target.dispatchEvent(event) + return event + } + + const zoomable = (config = {}): VectorMap => { + fixtureEl.innerHTML = '
' + return new VectorMap(el(), { map: squares, ...config }) + } + + it('should not react to the wheel or add buttons by default', () => { + fixtureEl.innerHTML = '
' + new VectorMap(el(), { map: squares }) + + const event = wheel(-500) + + expect(event.defaultPrevented).toBe(false) + expect(viewBox()).toEqual([0, 0, 30, 10]) + expect(el().querySelector('.vector-map-controls')).toBeNull() + expect(el().classList.contains('vector-map-zoomable')).toBe(false) + }) + + it('should add labelled buttons, with zoom out disabled on the whole map', () => { + zoomable({ zoomInLabel: 'Closer' }) + + expect(el().classList.contains('vector-map-zoomable')).toBe(true) + expect(button('zoom-in').getAttribute('aria-label')).toBe('Closer') + expect(button('zoom-out').getAttribute('aria-label')).toBe('Zoom out') + expect(button('zoom-in').disabled).toBe(false) + expect(button('zoom-out').disabled).toBe(true) + }) + + it('should zoom with the wheel around the pointer and keep the page from scrolling', () => { + zoomable() + + // exp(500 * 0.002) is a little under 3 times + const event = wheel(-500) + + expect(event.defaultPrevented).toBe(true) + const [x, y, width, height] = viewBox() + expect(width).toBeCloseTo(30 / Math.E, 1) + expect(height).toBeCloseTo(10 / Math.E, 1) + // The middle of the map was under the pointer, so it is still in the middle + expect(x + width / 2).toBeCloseTo(15, 0) + expect(y + height / 2).toBeCloseTo(5, 0) + expect(button('zoom-out').disabled).toBe(false) + }) + + it('should leave the wheel alone with zoomOnScroll off', () => { + zoomable({ zoomOnScroll: false }) + + expect(wheel(-500).defaultPrevented).toBe(false) + expect(viewBox()).toEqual([0, 0, 30, 10]) + }) + + it('should stop at the largest zoom and at the whole map', () => { + zoomable({ zoomMax: 2 }) + + wheel(-5000) + expect(viewBox()[2]).toBe(15) + expect(button('zoom-in').disabled).toBe(true) + + wheel(5000) + expect(viewBox()).toEqual([0, 0, 30, 10]) + }) + + it('should zoom in steps with the buttons', async () => { + zoomable() + + button('zoom-in').click() + await vi.waitFor(() => { + expect(viewBox()).toEqual([7.5, 2.5, 15, 5]) + }) + + button('zoom-out').click() + await vi.waitFor(() => { + expect(viewBox()).toEqual([0, 0, 30, 10]) + }) + }) + + it('should zoom in on a double click', async () => { + zoomable() + + region('AA').dispatchEvent(new MouseEvent('dblclick', { bubbles: true, clientX: el().getBoundingClientRect().left, clientY: el().getBoundingClientRect().top })) + + // The top left corner was under the pointer, so it stays in the corner + await vi.waitFor(() => { + expect(viewBox()).toEqual([0, 0, 15, 5]) + }) + }) + + it('should go back to the whole map on reset', async () => { + const map = zoomable() + wheel(-500) + + map.reset() + + await vi.waitFor(() => { + expect(viewBox()).toEqual([0, 0, 30, 10]) + }) + }) + + it('should drag the map with a pointer, but never out of sight', () => { + zoomable({ zoomMax: 2 }) + wheel(-5000) + expect(viewBox()).toEqual([7.5, 2.5, 15, 5]) + + // 300px show 15 units, so 40px is 2 units + pointer('pointerdown', 100, 50) + pointer('pointermove', 60, 50) + expect(viewBox()).toEqual([9.5, 2.5, 15, 5]) + expect(el().classList.contains('vector-map-dragging')).toBe(true) + + pointer('pointermove', -1000, 50) + expect(viewBox()).toEqual([15, 2.5, 15, 5]) + + pointer('pointerup', -1000, 50) + expect(el().classList.contains('vector-map-dragging')).toBe(false) + + pointer('pointermove', 0, 50) + expect(viewBox()).toEqual([15, 2.5, 15, 5]) + }) + + it('should not drag a map that cannot be zoomed', () => { + fixtureEl.innerHTML = '
' + new VectorMap(el(), { map: squares }) + + pointer('pointerdown', 100, 50) + pointer('pointermove', 60, 50) + + expect(el().classList.contains('vector-map-dragging')).toBe(false) + }) + + it('should zoom when two pointers move apart', () => { + zoomable() + const rect = el().getBoundingClientRect() + const y = rect.top + 50 + + pointer('pointerdown', rect.left + 140, y, 1) + pointer('pointerdown', rect.left + 160, y, 2) + pointer('pointermove', rect.left + 180, y, 2) + + // 20px between the fingers became 40px + expect(viewBox()[2]).toBe(15) + }) + + it('should start from the whole map again after render()', () => { + const map = zoomable() + wheel(-500) + + map.render() + + expect(viewBox()).toEqual([0, 0, 30, 10]) + expect(el().querySelectorAll('.vector-map-controls').length).toBe(1) + }) + }) + + describe('render', () => { + it('should replace the previous drawing', () => { + fixtureEl.innerHTML = '
' + + const map = new VectorMap(el(), { map: squares }) + map.render() + + expect(el().querySelectorAll('svg').length).toBe(1) + }) + }) + + describe('dispose', () => { + it('should remove the drawing', () => { + fixtureEl.innerHTML = '
' + + const map = new VectorMap(el(), { map: squares, zoom: true }) + region('AA').dispatchEvent(new PointerEvent('pointermove', { bubbles: true })) + map.dispose() + + expect(el().querySelector('svg')).toBeNull() + expect(el().querySelector('.vector-map-tooltip')).toBeNull() + expect(el().querySelector('.vector-map-controls')).toBeNull() + expect(el().classList.contains('vector-map-zoomable')).toBe(false) + expect(VectorMap.getInstance(el())).toBeNull() + }) + }) + + describe('world map', () => { + it('should have countries by their ISO code, each with a shape', () => { + expect(Object.keys(world.regions).length).toBeGreaterThan(170) + expect(world.regions.PL.name).toBe('Poland') + expect(world.regions.FR.name).toBe('France') + expect(world.regions.AQ).toBeUndefined() + + for (const [code, { path, point }] of Object.entries(world.regions)) { + expect(code).toMatch(/^[A-Z]{2}$/) + expect(path).toMatch(point ? /^M[\d. ]+h0$/ : /^M[-\d. ]+l[-\d. ]+z/) + } + }) + + it('should have the countries too small for a shape as points', () => { + for (const code of ['AD', 'SM', 'MC', 'LI', 'VA', 'MT', 'SG', 'HK', 'MO']) { + expect(world.regions[code].point).toBe(true) + } + + expect(world.regions.PL.point).toBeUndefined() + // Dependencies are left out + expect(world.regions.GI).toBeUndefined() + }) + + it('should draw inside its viewBox', () => { + fixtureEl.innerHTML = '
' + + new VectorMap(el(), { map: world }) + + const svg = el().querySelector('svg')! + // The shapes are rounded to one decimal place + const box = svg.getBBox() + expect(box.x).toBeGreaterThanOrEqual(-0.05) + expect(box.y).toBeGreaterThanOrEqual(-0.05) + expect(box.x + box.width).toBeLessThanOrEqual(world.width + 0.05) + expect(box.y + box.height).toBeLessThanOrEqual(world.height + 0.05) + }) + }) +}) diff --git a/core/libs.json b/core/libs.json index 2cfda5644..d88ae2c66 100644 --- a/core/libs.json +++ b/core/libs.json @@ -55,6 +55,16 @@ "js": ["dist/js/tom-select.base.min.js"], "css": ["dist/css/tom-select.bootstrap5.min.css"] }, + "vector-map": { + "addon": "tabler-vector-map", + "maps": ["world"] + }, + "maplibre-gl": { + "npm": "maplibre-gl", + "addon": "tabler-map", + "css": ["dist/maplibre-gl.css"], + "extra": ["dist/maplibre-gl.mjs", "dist/maplibre-gl-shared.mjs", "dist/maplibre-gl-worker.mjs"] + }, "jsvectormap": { "npm": "jsvectormap", "js": ["dist/jsvectormap.min.js", "dist/maps/world.js", "dist/maps/world-merc.js"], diff --git a/core/package.json b/core/package.json index 235d70785..72fbb734a 100644 --- a/core/package.json +++ b/core/package.json @@ -1,6 +1,6 @@ { "name": "@tabler/core", - "version": "1.6.1", + "version": "1.7.0", "description": "Premium and Open Source dashboard template with responsive and high quality UI.", "homepage": "https://tabler.io", "type": "module", @@ -18,12 +18,17 @@ "lint:scss:vars": "find-unused-sass-variables scss/ --ignoreFiles scss/_variables-deprecated.scss", "js": "pnpm run js:build && pnpm run js:min && pnpm run js:types", "js:types": "tsc -p tsconfig.types.json", - "js:build": "concurrently \"pnpm run js:build:standalone\" \"pnpm run js:build:theme\"", + "js:build": "concurrently \"pnpm run js:build:standalone\" \"pnpm run js:build:theme\" \"pnpm run js:build:map\" \"pnpm run js:build:vector-map\" \"pnpm run js:build:maps\"", + "js:build:vector-map": "cross-env BASE_NAME=tabler-vector-map vite build --config .build/vite.config.mts", + "js:build:maps": "cross-env BASE_NAME=maps/world vite build --config .build/vite.config.mts", "js:build:theme": "cross-env BASE_NAME=tabler-theme vite build --config .build/vite.config.mts", + "js:build:map": "cross-env BASE_NAME=tabler-map vite build --config .build/vite.config.mts", "js:build:standalone": "cross-env BASE_NAME=tabler vite build --config .build/vite.config.mts", - "js:min": "concurrently \"pnpm run js:min:standalone\" \"pnpm run js:min:theme\"", + "js:min": "concurrently \"pnpm run js:min:standalone\" \"pnpm run js:min:theme\" \"pnpm run js:min:map\" \"pnpm run js:min:vector-map\"", "js:min:standalone": "terser dist/js/tabler.js --module --compress passes=2,ecma=2020 --ecma 2020 --mangle --source-map \"content=dist/js/tabler.js.map,filename=dist/js/tabler.min.js.map,url=tabler.min.js.map\" -o dist/js/tabler.min.js", "js:min:theme": "terser dist/js/tabler-theme.js --module --compress passes=2,ecma=2020 --ecma 2020 --mangle --source-map \"content=dist/js/tabler-theme.js.map,filename=dist/js/tabler-theme.min.js.map,url=tabler-theme.min.js.map\" -o dist/js/tabler-theme.min.js", + "js:min:map": "terser dist/js/tabler-map.js --module --compress passes=2,ecma=2020 --ecma 2020 --mangle --source-map \"content=dist/js/tabler-map.js.map,filename=dist/js/tabler-map.min.js.map,url=tabler-map.min.js.map\" -o dist/js/tabler-map.min.js", + "js:min:vector-map": "terser dist/js/tabler-vector-map.js --module --compress passes=2,ecma=2020 --ecma 2020 --mangle --source-map \"content=dist/js/tabler-vector-map.js.map,filename=dist/js/tabler-vector-map.min.js.map,url=tabler-vector-map.min.js.map\" -o dist/js/tabler-vector-map.min.js", "copy": "concurrently \"pnpm run copy:img\" \"pnpm run copy:libs\"", "copy:img": "tsx .build/copy-img.ts", "copy:libs": "tsx .build/copy-libs.ts", @@ -103,19 +108,19 @@ "files": [ { "path": "./dist/css/tabler.css", - "maxSize": "88 kB" + "maxSize": "90 kB" }, { "path": "./dist/css/tabler.min.css", - "maxSize": "82 kB" + "maxSize": "84 kB" }, { "path": "./dist/css/tabler.rtl.css", - "maxSize": "88 kB" + "maxSize": "90 kB" }, { "path": "./dist/css/tabler.rtl.min.css", - "maxSize": "82 kB" + "maxSize": "84 kB" }, { "path": "./dist/css/tabler-flags.css", @@ -123,7 +128,7 @@ }, { "path": "./dist/css/tabler-flags.min.css", - "maxSize": "2.1 kB" + "maxSize": "2.5 kB" }, { "path": "./dist/css/tabler-flags.rtl.css", @@ -131,7 +136,7 @@ }, { "path": "./dist/css/tabler-flags.rtl.min.css", - "maxSize": "2.1 kB" + "maxSize": "2.5 kB" }, { "path": "./dist/css/tabler-marketing.css", @@ -215,27 +220,35 @@ }, { "path": "./dist/css/tabler-vendors.css", - "maxSize": "4 kB" + "maxSize": "5.5 kB" }, { "path": "./dist/css/tabler-vendors.min.css", - "maxSize": "3.5 kB" + "maxSize": "5 kB" }, { "path": "./dist/css/tabler-vendors.rtl.css", - "maxSize": "4 kB" + "maxSize": "5.5 kB" }, { "path": "./dist/css/tabler-vendors.rtl.min.css", - "maxSize": "3.5 kB" + "maxSize": "5 kB" }, { "path": "./dist/js/tabler.js", - "maxSize": "58 kB" + "maxSize": "64 kB" }, { "path": "./dist/js/tabler.min.js", - "maxSize": "40 kB" + "maxSize": "42 kB" + }, + { + "path": "./dist/js/tabler-map.js", + "maxSize": "11 kB" + }, + { + "path": "./dist/js/tabler-map.min.js", + "maxSize": "8 kB" }, { "path": "./dist/js/tabler-theme.js", @@ -244,6 +257,18 @@ { "path": "./dist/js/tabler-theme.min.js", "maxSize": "800 B" + }, + { + "path": "./dist/js/tabler-vector-map.js", + "maxSize": "13 kB" + }, + { + "path": "./dist/js/tabler-vector-map.min.js", + "maxSize": "9 kB" + }, + { + "path": "./dist/js/maps/world.js", + "maxSize": "28 kB" } ] }, @@ -257,7 +282,7 @@ }, "devDependencies": { "@melloware/coloris": "^0.25.0", - "@types/node": "^26.4.0", + "@types/node": "^26.6.5", "@vitest/browser-playwright": "4.1.11", "@vitest/coverage-istanbul": "4.1.11", "apexcharts": "7.0.0", @@ -265,20 +290,21 @@ "choices.js": "^11.2.4", "clipboard": "^2.0.11", "countup.js": "^2.10.1", - "driver.js": "^1.8.0", - "dropzone": "^6.0.0-beta.2", + "driver.js": "^1.10.1", + "dropzone": "^6.3.5", "find-unused-sass-variables": "^6.2.1", "flatpickr": "^4.6.13", - "fslightbox": "^3.7.6", - "fullcalendar": "^6.1.21", - "hugerte": "^1.0.12", + "fslightbox": "^3.8.0", + "fullcalendar": "^6.1.22", + "hugerte": "^1.0.14", "imask": "^7.6.1", "jsvectormap": "^1.7.0", "list.js": "^2.3.1", "litepicker": "^2.0.12", + "maplibre-gl": "^6.11.2", "nouislider": "^15.8.1", "playwright": "1.62.1", - "plyr": "^3.8.4", + "plyr": "^3.8.5", "sass": "1.103.1", "sass-true": "^10.1.0", "signature_pad": "^5.1.4", diff --git a/core/scss/_variables-dark.scss b/core/scss/_variables-dark.scss index 6b478bf5d..f5bd455a1 100644 --- a/core/scss/_variables-dark.scss +++ b/core/scss/_variables-dark.scss @@ -7,8 +7,9 @@ // $darken-dark: adjust-lightness-css(var(--dark), -2%) !default; $border-color-dark: adjust-lightness-css(var(--dark), 8%) !default; -$border-color-translucent-dark: rgba(128, 150, 172, 0.2) !default; +$border-color-translucent-dark: rgba(72, 110, 149, 0.14) !default; $border-dark-color-dark: adjust-lightness-css(var(--dark), 4%) !default; +$border-light-color-dark: adjust-lightness-css(var(--dark), 6%) !default; $border-active-color-dark: adjust-lightness-css(var(--dark), 12%) !default; //new bootsrap variables diff --git a/core/scss/_variables-deprecated.scss b/core/scss/_variables-deprecated.scss index 5f1b7edfe..d7fb6536f 100644 --- a/core/scss/_variables-deprecated.scss +++ b/core/scss/_variables-deprecated.scss @@ -58,3 +58,14 @@ $navbar-dark-toggler-icon-bg: url("data:image/svg+xml,") !default; +$number-input-icon-increment: url("data:image/svg+xml,") !default; +// scss-docs-end number-input-variables + // Cards // scss-docs-start card-variables $card-spacer-y: 1.25rem !default; @@ -1284,6 +1332,44 @@ $ribbon-bookmark-border-size: 1rem !default; $ribbon-bookmark-notch-width: 0.5rem !default; // scss-docs-end ribbon-variables +// scss-docs-start img-label-variables +$img-label-offset: 0.375rem !default; +$img-label-padding-y: 0.25rem !default; +$img-label-padding-x: 0.375rem !default; +$img-label-gap: 0.25rem !default; +$img-label-font-size: $h5-font-size !default; +$img-label-font-weight: var(--font-weight-medium) !default; +$img-label-color: $white !default; +$img-label-bg: rgba($black, 0.6) !default; +$img-label-border-radius: var(--border-radius-sm) !default; +$img-label-backdrop-filter: blur(4px) !default; +$img-label-icon-size: 0.75rem !default; +// scss-docs-end img-label-variables + +// scss-docs-start map-marker-variables +$map-marker-size: 1.75rem !default; +$map-marker-dot-size: 1rem !default; +$map-marker-bg: var(--primary) !default; +$map-marker-color: var(--primary-fg) !default; +$map-marker-border-width: 2px !default; +$map-marker-border-color: var(--bg-surface) !default; +$map-marker-font-size: $h5-font-size !default; +$map-marker-icon-size: 1rem !default; +$map-marker-shadow: drop-shadow(0 1px 2px rgba($black, 0.3)) !default; +$map-popup-card-width: 16rem !default; +$map-controls-offset: 0.625rem !default; +$map-controls-gap: 0.375rem !default; +$map-control-icon-size: 1rem !default; +// The shapes of the Tabler icons plus, minus, navigation, current-location and maximize +$map-control-icons: ( + 'zoom-in': "", + 'zoom-out': "", + 'north': "", + 'locate': "", + 'fullscreen': "", +) !default; +// scss-docs-end map-marker-variables + // Accordion // scss-docs-start accordion-variables $accordion-padding-y: 1rem !default; @@ -1361,6 +1447,8 @@ $btn-close-white-filter: invert(1) grayscale(100%) brightness(200%) !default; // $datagrid-padding: 1.5rem !default; $datagrid-item-width: 15rem !default; $datagrid-title-margin-bottom: 0.25rem !default; +$datagrid-title-width: 10rem !default; +$datagrid-columns: 4 !default; // scss-docs-end datagrid-variables // Empty @@ -1452,8 +1540,8 @@ $tooltip-arrow-height: 0.4rem !default; // Loader $loader-size: 2.5rem !default; -$list-group-header-bg: var(--bg-surface-tertiary) !default; -$list-group-header-color: var(--secondary) !default; +$list-group-header-bg: var(--fill-tertiary) !default; +$list-group-header-color: var(--secondary-color) !default; // Modals // scss-docs-start modal-variables @@ -1512,7 +1600,7 @@ $nav-link-padding-y: 0.5rem !default; $nav-link-padding-x: 0.75rem !default; $nav-link-font-size: var(--body-font-size) !default; $nav-link-font-weight: null !default; -$nav-link-color: var(--secondary) !default; +$nav-link-color: var(--secondary-color) !default; $nav-link-active-color: var(--body-color) !default; $nav-link-hover-color: var(--link-hover-color) !default; $nav-link-transition: @@ -1657,7 +1745,7 @@ $popover-arrow-height: 0.5rem !default; $footer-padding-y: 2rem !default; $footer-bg: var(--bg-surface) !default; $footer-border-color: var(--border-color) !default; -$footer-color: var(--gray-500) !default; +$footer-color: var(--secondary-color) !default; // Pagination // scss-docs-start pagination-variables @@ -1720,6 +1808,17 @@ $status-indicator-size: 2.5rem !default; $status-circle-size: 0.75rem !default; // scss-docs-end status-variables +// scss-docs-start hover-gallery-variables +$hover-gallery-aspect-ratio: 1 !default; +$hover-gallery-indicator-size: 2px !default; +$hover-gallery-indicator-gap: 0.25rem !default; +$hover-gallery-indicator-padding: 0.5rem !default; +$hover-gallery-indicator-color: $white !default; +$hover-gallery-indicator-scrim-height: 40% !default; +$hover-gallery-indicator-scrim-bg: linear-gradient(to top, rgba($black, 0.32), rgba($black, 0.2) 20%, rgba($black, 0.1) 45%, rgba($black, 0.03) 75%, transparent) !default; +$hover-gallery-indicator-inactive-opacity: 0.5 !default; +// scss-docs-end hover-gallery-variables + // scss-docs-start signal-variables $signal-size: map.get(map.get($avatar-sizes, 'xs'), size) !default; $signal-gap-ratio: 0.1 !default; @@ -1730,6 +1829,48 @@ $signal-bar-inactive-opacity: 0.24 !default; $signal-max-bars: 5 !default; // scss-docs-end signal-variables +// scss-docs-start text-shimmer-variables +$text-shimmer-duration: 2s !default; +$text-shimmer-opacity: 0.4 !default; +$text-shimmer-width: 20% !default; +$text-shimmer-fade-duration: 320ms !default; +$text-shimmer-fade-easing: cubic-bezier(0.22, 1, 0.36, 1) !default; +// Sized against 14px text: a 12px ring and 4px before the label +$text-shimmer-loader-size: math.div(12em, 14) !default; +$text-shimmer-loader-border-width: 0.1em !default; +$text-shimmer-loader-margin: math.div(4em, 14) !default; +$text-shimmer-loader-duration: 0.75s !default; +$text-shimmer-loader-opacity: 0.25 !default; +// scss-docs-end text-shimmer-variables + +// Mockup browser +// scss-docs-start mockup-browser-variables +$mockup-browser-bg: var(--bg-surface) !default; +$mockup-browser-border-color: var(--border-light-color) !default; +$mockup-browser-border-radius: var(--border-radius-lg) !default; +$mockup-browser-box-shadow: var(--shadow-lg) !default; +$mockup-browser-toolbar-min-height: 2rem !default; +$mockup-browser-toolbar-padding-y: 0.5rem !default; +$mockup-browser-toolbar-padding-x: 12.5% !default; +$mockup-browser-toolbar-color: var(--secondary) !default; +$mockup-browser-toolbar-bg: var(--bg-surface-secondary) !default; +$mockup-browser-control-offset: 1rem !default; +$mockup-browser-control-size: 0.75rem !default; +$mockup-browser-control-gap: 0.5rem !default; +$mockup-browser-control-colors: #ff5f57, #febc2e, #28c840 !default; +$mockup-browser-url-font-size: $h5-font-size !default; +$mockup-browser-url-line-height: 1rem !default; +$mockup-browser-url-icon-size: 0.875rem !default; + +$mockup-browser-sm-border-radius: var(--border-radius) !default; +$mockup-browser-sm-toolbar-min-height: 1.5rem !default; +$mockup-browser-sm-toolbar-padding-y: 0.25rem !default; +$mockup-browser-sm-control-size: 0.5rem !default; +$mockup-browser-sm-control-gap: 0.25rem !default; +$mockup-browser-sm-url-font-size: $h6-font-size !default; +$mockup-browser-sm-url-icon-size: 0.75rem !default; +// scss-docs-end mockup-browser-variables + // Steps // scss-docs-start steps-variables $steps-border-width: 2px !default; @@ -1833,7 +1974,7 @@ $toast-border-radius: var(--border-radius) !default; $toast-box-shadow: var(--shadow) !default; $toast-spacing: $container-padding-x !default; -$toast-header-color: var(--gray-500) !default; +$toast-header-color: var(--secondary-color) !default; $toast-header-background-color: color-transparent(var(--body-bg), 0.85) !default; $toast-header-border-color: $toast-border-color !default; // scss-docs-end toast-variables @@ -1862,7 +2003,7 @@ $sortable-drag-box-shadow: $box-shadow-lg !default; // scss-docs-start progress-variables $progress-height: 0.5rem !default; $progress-font-size: $font-size-base * 0.75 !default; -$progress-bg: var(--border-color) !default; +$progress-bg: var(--fill-secondary) !default; $progress-border-radius: var(--border-radius) !default; $progress-box-shadow: var(--box-shadow-inset) !default; $progress-bar-color: $white !default; @@ -1904,7 +2045,7 @@ $list-group-border-color: var(--border-color) !default; $list-group-border-width: var(--border-width) !default; $list-group-border-radius: var(--border-radius) !default; -$list-group-item-padding-y: $card-cap-padding-y !default; +$list-group-item-padding-y: 1rem !default; $list-group-item-padding-x: $card-cap-padding-x !default; $list-group-hover-bg: var(--hover-bg) !default; @@ -1920,11 +2061,26 @@ $list-group-action-hover-color: var(--emphasis-color) !default; $list-group-action-active-color: var(--body-color) !default; $list-group-action-active-bg: var(--active-bg) !default; $list-group-header-padding-y: 0.5rem !default; +$list-group-item-gap: 1rem !default; +$list-group-nested-indent: 2.25rem !default; +$list-group-item-padding-y-sm: 0.5rem !default; +$list-group-item-gap-sm: 0.75rem !default; +$list-group-item-padding-y-lg: 1.5rem !default; +$list-group-item-gap-lg: 1.25rem !default; // Color variants: a tinted row, matched to the `bg-{color}-lt` utilities $list-group-variant-border-alpha: 0.24 !default; $list-group-variant-hover-alpha: 0.16 !default; +// Icon list +// scss-docs-start list-icon-variables +$list-icon-gap: 0.5rem !default; +$list-icon-row-gap: 0.5rem !default; +$list-icon-inline-gap: 1.5rem !default; +$list-icon-size: 1rem !default; +$list-icon-color: var(--secondary) !default; +// scss-docs-end list-icon-variables + // Forms $input-disabled-bg: var(--bg-forms-disabled) !default; $input-border-color: var(--border-color) !default; @@ -1935,8 +2091,8 @@ $input-placeholder-color: var(--tertiary) !default; $input-group-addon-padding-y: $input-padding-y !default; $input-group-addon-padding-x: $input-padding-x !default; $input-group-addon-font-weight: $input-font-weight !default; -$input-group-addon-color: var(--gray-500) !default; -$input-group-addon-bg: var(--bg-surface-secondary) !default; +$input-group-addon-color: var(--secondary-color) !default; +$input-group-addon-bg: var(--bg-surface-tertiary) !default; $input-group-addon-border-color: $input-border-color !default; $input-border-radius: var(--border-radius) !default; @@ -2012,7 +2168,8 @@ $form-select-font-size-lg: $input-font-size-lg !default; $form-select-border-radius-lg: $input-border-radius-lg !default; $form-select-transition: $input-transition !default; -$form-switch-color: light-dark(var(--gray-200), #{color-transparent(var(--white), 0.25)}) !default; +$form-switch-bg: light-dark(var(--gray-300), #{color-transparent(var(--white), 0.25)}) !default; +$form-switch-color: var(--white) !default; $form-switch-width: 2rem !default; $form-switch-height: 1.25rem !default; $form-switch-padding-start: $form-switch-width + 0.5rem !default; @@ -2022,7 +2179,7 @@ $form-switch-transition: border-color 0.15s ease-in-out, $focus-ring-transition !default; $form-switch-knob-transition: inset-inline-start 0.15s ease-in-out !default; -$form-switch-focus-color: mix-color-css(var(--white), var(--primary), 50%) !default; +$form-switch-focus-color: var(--white) !default; $form-switch-checked-color: var(--white) !default; $form-switch-disabled-bg: $input-disabled-bg !default; $form-switch-disabled-color: light-dark(var(--gray-400), var(--gray-600)) !default; @@ -2031,7 +2188,7 @@ $form-switch-disabled-color: light-dark(var(--gray-400), var(--gray-600)) !defau $form-range-track-width: 100% !default; $form-range-track-height: 0.25rem !default; $form-range-track-cursor: pointer !default; -$form-range-track-bg: var(--border-color) !default; +$form-range-track-bg: var(--fill-primary) !default; $form-range-track-border-radius: 1rem !default; $form-range-track-box-shadow: var(--box-shadow-inset) !default; @@ -2273,6 +2430,29 @@ $sparkline-sizes: ( ) !default; // scss-docs-end sparkline-variables +// scss-docs-start vector-map-variables +$vector-map-color: var(--primary) !default; +$vector-map-region-fill: var(--border-color) !default; +$vector-map-region-stroke: var(--bg-surface) !default; +$vector-map-region-stroke-width: 1px !default; +$vector-map-value-min: 0.1 !default; +$vector-map-colors-max: 5 !default; +$vector-map-point-size: 0.375rem !default; +$vector-map-selected-stroke: var(--body-color) !default; +$vector-map-selected-stroke-width: 2px !default; +$vector-map-marker-size: 0.5rem !default; +$vector-map-marker-color: var(--vector-map-color) !default; +$vector-map-marker-halo-color: var(--bg-surface) !default; +$vector-map-marker-halo-width: 2px !default; +$vector-map-line-color: var(--secondary) !default; +$vector-map-line-width: 1px !default; +$vector-map-line-dash: 4px !default; +$vector-map-line-duration: 1s !default; +$vector-map-controls-offset: var(--spacer-2) !default; +$vector-map-legend-width: 8rem !default; +$vector-map-legend-height: 0.5rem !default; +// scss-docs-end vector-map-variables + // Patterns $pattern-size: 0.5rem !default; $pattern-size-sm: 0.25rem !default; @@ -2567,10 +2747,11 @@ $flag-box-shadow: var(--shadow-border) !default; $flag-border-radius: var(--border-radius-sm) !default; $flag-sizes: $avatar-sizes !default; $flag-countries: ( + 'abkhazia', 'ad', - 'af', 'ae', - 'afrun', + 'af', + 'african-union', 'ag', 'ai', 'al', @@ -2578,7 +2759,9 @@ $flag-countries: ( 'ao', 'aq', 'ar', + 'arab-league', 'as', + 'asean', 'at', 'au', 'aw', @@ -2597,9 +2780,10 @@ $flag-countries: ( 'bm', 'bn', 'bo', - 'bq-bo', - 'bq-sa', - 'bq-se', + 'bq', + 'bq1', + 'bq2', + 'bq3', 'br', 'bs', 'bt', @@ -2608,17 +2792,23 @@ $flag-countries: ( 'by', 'bz', 'ca', + 'cabinda', 'cc', 'cd', + 'cefta', 'cf', 'cg', 'ch', + 'chechnya', + 'chequered', 'ci', 'ck', 'cl', 'cm', 'cn', 'co', + 'commonwealth-of-independent-states', + 'commonwealth-of-nations', 'cr', 'cu', 'cv', @@ -2627,17 +2817,23 @@ $flag-countries: ( 'cy', 'cz', 'de', + 'dg', + 'diver-down', 'dj', 'dk', 'dm', 'do', 'dz', + 'eac', 'ec', 'ee', 'eg', 'eh', 'er', 'es', + 'es-ct', + 'es-ga', + 'es-pv', 'et', 'eu', 'fi', @@ -2647,11 +2843,11 @@ $flag-countries: ( 'fo', 'fr', 'ga', - 'gb-eng', - 'gb-sct', 'gb', - 'gb-wls', + 'gb-eng', 'gb-nir', + 'gb-sct', + 'gb-wls', 'gd', 'ge', 'gf', @@ -2675,6 +2871,7 @@ $flag-countries: ( 'hr', 'ht', 'hu', + 'ic', 'id', 'ie', 'il', @@ -2689,6 +2886,7 @@ $flag-countries: ( 'jm', 'jo', 'jp', + 'jubaland', 'ke', 'kg', 'kh', @@ -2697,6 +2895,7 @@ $flag-countries: ( 'kn', 'kp', 'kr', + 'kurdistan', 'kw', 'ky', 'kz', @@ -2712,9 +2911,11 @@ $flag-countries: ( 'lv', 'ly', 'ma', + 'maltese-cross', 'mc', 'md', 'me', + 'mercosur', 'mf', 'mg', 'mh', @@ -2735,6 +2936,7 @@ $flag-countries: ( 'my', 'mz', 'na', + 'nato', 'nc', 'ne', 'nf', @@ -2742,16 +2944,23 @@ $flag-countries: ( 'ni', 'nl', 'no', + 'nordic-council', + 'northern-cyprus', 'np', 'nr', 'nu', 'nz', + 'olympic', 'om', 'pa', + 'paneuropean-union', + 'paraolympic', + 'pc', 'pe', 'pf', 'pg', 'ph', + 'pirate', 'pk', 'pl', 'pm', @@ -2764,6 +2973,8 @@ $flag-countries: ( 'qa', 'rainbow', 're', + 'red-crescent', + 'red-cross', 'ro', 'rs', 'ru', @@ -2773,8 +2984,13 @@ $flag-countries: ( 'sc', 'sd', 'se', + 'sealand', + 'semaphore-signal', 'sg', 'sh', + 'sh-ac', + 'sh-hl', + 'sh-ta', 'si', 'sj', 'sk', @@ -2782,6 +2998,9 @@ $flag-countries: ( 'sm', 'sn', 'so', + 'somaliland', + 'south-american-union', + 'south-ossetia', 'sr', 'ss', 'st', @@ -2789,11 +3008,13 @@ $flag-countries: ( 'sx', 'sy', 'sz', + 'tatarstan', 'tc', 'td', 'tf', 'tg', 'th', + 'tibet', 'tj', 'tk', 'tl', @@ -2801,14 +3022,17 @@ $flag-countries: ( 'tn', 'to', 'tr', + 'transnistria', 'tt', + 'turkic-states', 'tv', 'tw', 'tz', 'ua', 'ug', 'um', - 'unasur', + 'un', + 'unknown', 'us', 'uy', 'uz', @@ -2821,6 +3045,7 @@ $flag-countries: ( 'vu', 'wf', 'ws', + 'xk', 'ye', 'yt', 'za', diff --git a/core/scss/bootstrap/forms/_form-check.scss b/core/scss/bootstrap/forms/_form-check.scss index 320bdd0d5..41cef1f09 100644 --- a/core/scss/bootstrap/forms/_form-check.scss +++ b/core/scss/bootstrap/forms/_form-check.scss @@ -157,6 +157,7 @@ --form-switch-height: #{$form-switch-height}; --form-switch-padding-start: #{if(sass($form-switch-padding-start == $form-switch-width + 0.5rem): calc(var(--form-switch-width) + 0.5rem); else: $form-switch-padding-start)}; --form-switch-border-radius: #{if(sass($form-switch-border-radius == $form-switch-width): var(--form-switch-width); else: $form-switch-border-radius)}; + --form-switch-bg: #{$form-switch-bg}; --form-switch-focus-color: #{$form-switch-focus-color}; --form-switch-checked-color: #{$form-switch-checked-color}; --form-switch-disabled-bg: #{var-if-default($form-switch-disabled-bg, var(--bg-forms-disabled), '--control-disabled-bg')}; @@ -167,10 +168,12 @@ .form-check-input { --form-switch-color: #{$form-switch-color}; - // The knob is a circle three quarters of the track height, inset by an eighth - // — the same geometry the old `viewBox='-4 -4 8 8'` svg had. - --form-switch-knob-size: calc(var(--form-switch-height) * 0.75); - --form-switch-knob-offset: calc(var(--form-switch-height) * 0.125); + // The knob is a circle with the same gap to the outer edge of the track on + // every side. The gap is a whole number of pixels, so the knob stays round. + --form-switch-knob-offset: 0.125rem; + --form-switch-knob-size: calc(var(--form-switch-height) - var(--form-switch-knob-offset) * 2); + // The knob is positioned inside the border, the gap is measured from outside it. + --form-switch-knob-inset: calc(var(--form-switch-knob-offset) - var(--border-width)); width: var(--form-switch-width); margin-inline-start: calc(var(--form-switch-padding-start) * -1); @@ -180,7 +183,7 @@ // The knob replaces the masked check icon: a plain circle that slides. &::before { inset-block: 0; - inset-inline: var(--form-switch-knob-offset) auto; + inset-inline: var(--form-switch-knob-inset) auto; width: var(--form-switch-knob-size); height: var(--form-switch-knob-size); margin-block: auto; @@ -192,6 +195,13 @@ @include transition($form-switch-knob-transition); } + // An off switch is a filled track with no outline, so it reads as a + // switch and not as an empty input. + &:not(:checked, :disabled) { + background-color: var(--form-switch-bg); + border-color: transparent; + } + &:focus { --form-switch-color: var(--form-switch-focus-color); } @@ -200,7 +210,7 @@ --form-switch-color: var(--form-switch-checked-color); &::before { - inset-inline-start: calc(100% - var(--form-switch-knob-size) - var(--form-switch-knob-offset)); + inset-inline-start: calc(100% - var(--form-switch-knob-size) - var(--form-switch-knob-inset)); } } } diff --git a/core/scss/layout/_navbar.scss b/core/scss/layout/_navbar.scss index 3fa472faf..64bcf344e 100644 --- a/core/scss/layout/_navbar.scss +++ b/core/scss/layout/_navbar.scss @@ -112,6 +112,7 @@ .dropdown-menu { position: static; + z-index: auto; min-width: 0; padding: 0; margin: 0; @@ -261,6 +262,7 @@ position: absolute; inset-inline-start: 100%; top: 0; + z-index: $zindex-fixed; min-width: var(--dropdown-min-width); padding: var(--dropdown-padding-y) var(--dropdown-padding-x); margin: 0; @@ -453,8 +455,15 @@ } } + // ~ .navbar covers the horizontal navbar's own header row; the + // two-row navbar's menu row is a sibling wrapper that carries + // navbar-expand-* but not .navbar itself (the .navbar it wraps is + // nested, not a direct sibling), so it needs its own clause — only + // reachable together with .navbar-vertical since the position rules + // above otherwise keep the two navbars mutually exclusive. &.navbar-vertical { ~ .navbar, + ~ [class*='navbar-expand']:not(.navbar-vertical), ~ .page-wrapper { margin-inline-start: calc(var(--sidebar-width) + var(--sidebar-gap, 0px)); } @@ -463,6 +472,7 @@ &.navbar-vertical.navbar-right, &.navbar-vertical.navbar-end { ~ .navbar, + ~ [class*='navbar-expand']:not(.navbar-vertical), ~ .page-wrapper { margin-inline-start: 0; margin-inline-end: calc(var(--sidebar-width) + var(--sidebar-gap, 0px)); @@ -655,7 +665,8 @@ html[data-bs-navbar-theme='primary'] .page > [class*='navbar-expand'] .navbar { // The class sits on the aside, so its layout siblings need their own copy. ~ .page, ~ .page-wrapper, - ~ .navbar { + ~ .navbar, + ~ [class*='navbar-expand']:not(.navbar-vertical) { --sidebar-width: var(--sidebar-folded-width); } } @@ -663,23 +674,28 @@ html[data-bs-navbar-theme='primary'] .page > [class*='navbar-expand'] .navbar { // Navbar position (data-bs-navbar-position on ). A page can render both // navbars — the vertical aside first, the horizontal navbar(s) after — and // these rules show one of them. Horizontal is the default and writes no - // attribute, so its rule matches on the attribute's absence. + // attribute, so its rule matches on the attribute's absence. 'combined' + // opts out of both rules, so a page that renders both navbars keeps them + // both on screen — the pre-1.5 behavior, back as an explicit choice. // // Both rules are guarded with :has() so they only fire on a page that really // renders the other navbar: a page with a single navbar keeps it, whatever a // stale stored choice says, and can never end up with no navigation at all. - html:not([data-bs-navbar-position='vertical']) .page:has(> [class*='navbar-expand']:not(.navbar-vertical)) { + html:not([data-bs-navbar-position='vertical']):not([data-bs-navbar-position='combined']) .page:has(> [class*='navbar-expand']:not(.navbar-vertical)) { > .navbar-vertical { display: none; } // The hidden aside must leave no gap: its siblings offset themselves by // --sidebar-width, and this declaration sits on the same elements as the - // folded copy above, so it wins by coming later. + // folded copy above, so it wins by coming later. The two-row navbar's + // menu row is included too — it carries navbar-expand-* but not .navbar + // itself, and would otherwise keep reading the un-zeroed --sidebar-width. // A length, not a bare 0: the offset is added up in calc(). A floating // navbar keeps its own gap from the viewport edge. > .navbar, - > .page-wrapper { + > .page-wrapper, + > [class*='navbar-expand']:not(.navbar-vertical) { --sidebar-width: 0px; --sidebar-gap: var(--navbar-margin, 0px); } diff --git a/core/scss/layout/_root.scss b/core/scss/layout/_root.scss index 5c6820c59..65a64de97 100644 --- a/core/scss/layout/_root.scss +++ b/core/scss/layout/_root.scss @@ -16,11 +16,15 @@ --bg-surface: light-dark(var(--bg-surface-primary), var(--gray-800)); --bg-surface-primary: var(--white); --bg-surface-secondary: light-dark(var(--gray-50), var(--gray-900)); - --bg-surface-tertiary: light-dark(var(--gray-50), var(--gray-800)); + // Translucent, so a card header or footer, a table stripe or an input-group + // addon stands out from whatever surface it sits on, in both color modes + // (an opaque gray-800 was invisible on a dark card). + --bg-surface-tertiary: light-dark(#{color-transparent(var(--body-color), 0.025)}, #{color-transparent(var(--body-color), 0.04)}); --bg-surface-dark: var(--gray-900); --bg-surface-inverted: light-dark(var(--gray-900), var(--gray-100)); --bg-forms: light-dark(var(--bg-surface), var(--gray-900)); - --bg-forms-disabled: light-dark(var(--bg-surface-secondary), var(--gray-800)); + // Dark: translucent, like the tertiary surface, so a disabled control on a card is not the card's own gray. + --bg-forms-disabled: light-dark(var(--bg-surface-secondary), #{color-transparent(var(--body-color), 0.04)}); --text-inverted: light-dark(var(--gray-100), var(--gray-800)); @@ -33,19 +37,26 @@ --link-hover-color: light-dark(#{mix-color-css(var(--black), var(--link-color), 20%)}, #{mix-color-css(var(--white), var(--link-color), 20%)}); --secondary: light-dark(var(--gray-500), var(--gray-400)); - --tertiary: var(--gray-400); + --muted: light-dark(var(--gray-500), var(--gray-400)); + --tertiary: light-dark(var(--gray-500), var(--gray-400)); --border-color: light-dark(#{$border-color}, var(--gray-700)); --border-color-translucent: light-dark(#{$border-color-translucent}, var(--dark-mode-border-color-translucent)); --border-dark-color: light-dark(#{$border-dark-color}, var(--dark-mode-border-dark-color)); --border-dark-color-translucent: #{$border-dark-color-translucent}; - --border-light-color: #{$border-light-color}; + --border-light-color: light-dark(#{$border-light-color}, var(--dark-mode-border-light-color)); --border-light-color-translucent: #{$border-light-color-translucent}; --border-active-color: light-dark(#{$border-active-color}, var(--dark-mode-border-active-color)); --border-active-color-translucent: #{$border-active-color-translucent}; --icon-color: #{$icon-color}; + --fill-primary: #{$fill-primary}; + --fill-secondary: #{$fill-secondary}; + --fill-tertiary: #{$fill-tertiary}; + --fill-quaternary: #{$fill-quaternary}; + --fill: var(--fill-primary); + --active-bg: #{$active-bg}; --hover-bg: #{$hover-bg}; @@ -61,6 +72,7 @@ --dark-mode-border-color-translucent: #{$border-color-translucent-dark}; --dark-mode-border-active-color: #{$border-active-color-dark}; --dark-mode-border-dark-color: #{$border-dark-color-dark}; + --dark-mode-border-light-color: #{$border-light-color-dark}; --page-padding: #{$page-padding}; --page-padding-y: #{$page-padding-y}; diff --git a/core/scss/marketing/_index.scss b/core/scss/marketing/_index.scss index 2a821e0b5..5a800f325 100644 --- a/core/scss/marketing/_index.scss +++ b/core/scss/marketing/_index.scss @@ -4,5 +4,3 @@ @forward 'browser'; @forward 'sections'; @forward 'filters'; -@forward 'pricing'; -@forward 'shape'; diff --git a/core/scss/mixins/_mixins.scss b/core/scss/mixins/_mixins.scss index 8cbf15a70..053132198 100644 --- a/core/scss/mixins/_mixins.scss +++ b/core/scss/mixins/_mixins.scss @@ -187,9 +187,9 @@ } #{btn-color-selectors($colors, '.btn-{c}')} { - --btn-border-color: transparent; - --btn-hover-border-color: transparent; - --btn-active-border-color: transparent; + --btn-border-color: #{$btn-variant-border-color}; + --btn-hover-border-color: #{$btn-variant-border-color}; + --btn-active-border-color: #{$btn-variant-border-color}; --btn-color: var(--btn-variant-fg, #{$white}); --btn-bg: var(--btn-variant); --btn-hover-color: var(--btn-variant-fg); @@ -198,7 +198,12 @@ --btn-active-bg: var(--btn-variant-darken); --btn-disabled-bg: var(--btn-variant); --btn-disabled-color: var(--btn-variant-fg); - --btn-box-shadow: var(--shadow-input); + // A lighter 1px edge along the top, a darker one along the bottom and a faint + // white gradient, so the filled button reads as slightly raised. The press + // state replaces the edges with `--btn-active-shadow`. + --btn-box-shadow: inset 0 1px 0 0 #{$btn-highlight-color}, inset 0 -1px 0 0 #{$btn-shade-color}, var(--shadow-input); + --btn-gradient: #{$btn-gradient}; + background-image: var(--btn-gradient); } @if list.index($colors, 'dark') { @@ -221,6 +226,8 @@ --btn-active-border-color: var(--btn-variant); --btn-disabled-color: var(--btn-variant); --btn-disabled-border-color: var(--btn-variant); + --btn-gradient: none; + --btn-box-shadow: none; } #{btn-color-selectors($colors, '.btn-ghost-{c}', '.btn-ghost.btn-{c}')} { @@ -238,6 +245,7 @@ --btn-disabled-bg: transparent; --btn-disabled-border-color: transparent; --gradient: none; + --btn-gradient: none; --btn-box-shadow: none; } } diff --git a/core/scss/tabler-themes.scss b/core/scss/tabler-themes.scss index 628c43c95..3944efd01 100644 --- a/core/scss/tabler-themes.scss +++ b/core/scss/tabler-themes.scss @@ -76,20 +76,19 @@ --gray-950: oklch(14.69% 0.0041 49.25deg); // #0c0a09 } -// Easter egg: not listed in the theme settings panel, enable with `data-bs-theme-base="pink"` or `?theme-base=pink` -[data-bs-theme-base='pink'], -[data-theme-base='pink'] { - --gray-50: oklch(97.14% 0.0141 343.2deg); // #fdf2f8 - --gray-100: oklch(94.82% 0.0276 342.26deg); // #fce7f3 - --gray-200: oklch(89.94% 0.0589 343.23deg); // #fbcfe8 - --gray-300: oklch(82.28% 0.1095 346.02deg); // #f9a8d4 - --gray-400: oklch(72.53% 0.1752 349.76deg); // #f472b6 - --gray-500: oklch(65.59% 0.2118 354.31deg); // #ec4899 - --gray-600: oklch(59.16% 0.218 0.58deg); // #db2777 - --gray-700: oklch(52.46% 0.199 3.96deg); // #be185d - --gray-800: oklch(45.87% 0.1697 3.82deg); // #9d174d - --gray-900: oklch(40.78% 0.1442 2.43deg); // #831843 - --gray-950: oklch(28.45% 0.1048 3.91deg); // #500724 +[data-bs-theme-base='mist'], +[data-theme-base='mist'] { + --gray-50: oklch(98.7% 0.002 197.4deg); // #f9fbfb + --gray-100: oklch(96.3% 0.002 197.4deg); // #f1f3f3 + --gray-200: oklch(92.5% 0.005 214.3deg); // #e3e7e8 + --gray-300: oklch(87.2% 0.007 219.5deg); // #d0d6d8 + --gray-400: oklch(72.3% 0.014 214.4deg); // #9ca8ab + --gray-500: oklch(56% 0.021 213.5deg); // #67787c + --gray-600: oklch(45% 0.017 213.2deg); // #4b585b + --gray-700: oklch(37.8% 0.015 216deg); // #394447 + --gray-800: oklch(27.5% 0.011 216.9deg); // #22292b + --gray-900: oklch(21.8% 0.008 223.9deg); // #161b1d + --gray-950: oklch(14.8% 0.004 228.8deg); // #090b0c } @each $name, $value in $extra-colors { diff --git a/core/scss/tests/__snapshots__/css-var-prefix.test.mjs.snap b/core/scss/tests/__snapshots__/css-var-prefix.test.mjs.snap index cc24d90a3..e2f2b6186 100644 --- a/core/scss/tests/__snapshots__/css-var-prefix.test.mjs.snap +++ b/core/scss/tests/__snapshots__/css-var-prefix.test.mjs.snap @@ -37,31 +37,47 @@ exports[`css custom-property prefixing > leaves vendor-owned names alone and pre "--plyr-color-main", "--tblr-active-bg", "--tblr-azure", + "--tblr-azure-fg", "--tblr-backdrop-bg", "--tblr-bg-forms", "--tblr-bg-surface", "--tblr-bg-surface-inverted", "--tblr-bg-surface-secondary", "--tblr-black", + "--tblr-blue", + "--tblr-blue-fg", "--tblr-body-bg", "--tblr-body-color", "--tblr-border-color", "--tblr-border-color-translucent", "--tblr-border-radius", + "--tblr-border-radius-pill", + "--tblr-border-radius-sm", "--tblr-border-style", "--tblr-border-width", + "--tblr-btn-active-bg", + "--tblr-btn-bg", + "--tblr-btn-hover-bg", "--tblr-card-bg", "--tblr-chart-1", "--tblr-chart-2", "--tblr-chart-3", "--tblr-chart-4", "--tblr-chart-5", + "--tblr-control-disabled-bg", + "--tblr-control-disabled-border-color", "--tblr-control-font-size", "--tblr-control-line-height", "--tblr-control-padding-x", "--tblr-control-padding-y", "--tblr-control-placeholder-color", + "--tblr-cyan", + "--tblr-cyan-fg", "--tblr-danger", + "--tblr-danger-fg", + "--tblr-dark", + "--tblr-dark-fg", + "--tblr-fill-primary", "--tblr-focus-ring", "--tblr-focus-ring-color", "--tblr-focus-ring-offset", @@ -79,25 +95,58 @@ exports[`css custom-property prefixing > leaves vendor-owned names alone and pre "--tblr-form-valid-border-color", "--tblr-gray-500", "--tblr-green", + "--tblr-green-fg", "--tblr-icon-size", + "--tblr-indigo", + "--tblr-indigo-fg", "--tblr-info", + "--tblr-info-fg", + "--tblr-input-icon-padding-start", "--tblr-light", + "--tblr-light-fg", + "--tblr-lime", + "--tblr-lime-fg", + "--tblr-map-control-icon", + "--tblr-map-marker-bg", + "--tblr-map-marker-border-color", + "--tblr-map-marker-border-width", + "--tblr-map-marker-color", + "--tblr-map-marker-font-size", + "--tblr-map-marker-icon-size", + "--tblr-map-marker-size", + "--tblr-muted", + "--tblr-muted-fg", "--tblr-orange", + "--tblr-orange-fg", + "--tblr-pink", + "--tblr-pink-fg", "--tblr-primary", "--tblr-primary-200", + "--tblr-primary-fg", "--tblr-primary-lt", "--tblr-purple", + "--tblr-purple-fg", + "--tblr-red", + "--tblr-red-fg", "--tblr-secondary", "--tblr-secondary-bg", + "--tblr-secondary-fg", "--tblr-shadow-card", + "--tblr-shadow-color", "--tblr-shadow-dropdown", "--tblr-shadow-input", "--tblr-signal-color", "--tblr-success", + "--tblr-success-fg", + "--tblr-teal", + "--tblr-teal-fg", "--tblr-tertiary", "--tblr-text-inverted", + "--tblr-warning", + "--tblr-warning-fg", "--tblr-white", "--tblr-yellow", + "--tblr-yellow-fg", "--ts-pr-caret", "--ts-pr-clear-button", ] diff --git a/core/scss/tests/fill-colors.test.mjs b/core/scss/tests/fill-colors.test.mjs new file mode 100644 index 000000000..19f76f3c1 --- /dev/null +++ b/core/scss/tests/fill-colors.test.mjs @@ -0,0 +1,32 @@ +// Guards the neutral fill tokens. They are tuned to match the design file, so +// a lost light-dark() pair or a changed amount shifts badges, avatars, code, +// progress tracks and the hover background without any other test noticing. +import { describe, expect, it } from 'vitest' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { compile as compileSass } from 'sass' + +const scssDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const { css } = compileSass(path.join(scssDir, 'tabler.scss'), { loadPaths: ['node_modules'], style: 'expanded' }) + +// Custom properties are bare here: the `--tblr-` prefix is a build-time postcss pass. +const flat = css.replace(/\s+/g, ' ') + +describe('neutral fills', () => { + it('declares the four levels and the --fill alias', () => { + expect(flat).toContain('--fill-primary: color-mix(in oklab, var(--body-color) 16%, transparent);') + expect(flat).toContain('--fill-secondary: color-mix(in oklab, var(--body-color) 8%, transparent);') + expect(flat).toContain('--fill-tertiary: light-dark(color-mix(in oklab, var(--body-color) 4%, transparent), color-mix(in oklab, var(--body-color) 6%, transparent));') + expect(flat).toContain('--fill-quaternary: color-mix(in oklab, var(--body-color) 2%, transparent);') + expect(flat).toContain('--fill: var(--fill-primary);') + }) + + it('points the hover background at the tertiary fill', () => { + expect(flat).toContain('--hover-bg: var(--fill-tertiary);') + }) + + it('ships .bg-fill as an alias of .bg-fill-primary', () => { + expect(flat).toContain('.bg-fill { background-color: var(--fill) !important; }') + expect(flat).toContain('.bg-fill-quaternary { background-color: var(--fill-quaternary) !important; }') + }) +}) diff --git a/core/scss/tests/layout-attributes.test.mjs b/core/scss/tests/layout-attributes.test.mjs index b81c9b074..70a429422 100644 --- a/core/scss/tests/layout-attributes.test.mjs +++ b/core/scss/tests/layout-attributes.test.mjs @@ -36,11 +36,11 @@ describe('sticky navbar', () => { describe('navbar position', () => { it('hides the sidebar when the attribute is absent, which is the horizontal default', () => { - expect(flat).toContain('html:not([data-bs-navbar-position=vertical]) .page:has(> [class*=navbar-expand]:not(.navbar-vertical)) > .navbar-vertical { display: none; }') + expect(flat).toContain('html:not([data-bs-navbar-position=vertical]):not([data-bs-navbar-position=combined]) .page:has(> [class*=navbar-expand]:not(.navbar-vertical)) > .navbar-vertical { display: none; }') }) - it('drops the sidebar offset from the page with no sidebar on it', () => { - expect(flat).toMatch(/html:not\(\[data-bs-navbar-position=vertical\]\) \.page:has\(> \[class\*=navbar-expand\]:not\(\.navbar-vertical\)\) > \.navbar,[^{]*> \.page-wrapper \{ --sidebar-width: 0px; --sidebar-gap: var\(--navbar-margin, 0px\); \}/) + it('drops the sidebar offset from the page with no sidebar on it, including the two-row navbar menu row', () => { + expect(flat).toMatch(/html:not\(\[data-bs-navbar-position=vertical\]\):not\(\[data-bs-navbar-position=combined\]\) \.page:has\(> \[class\*=navbar-expand\]:not\(\.navbar-vertical\)\) > \.navbar,[^{]*> \.page-wrapper,[^{]*> \[class\*=navbar-expand\]:not\(\.navbar-vertical\) \{ --sidebar-width: 0px; --sidebar-gap: var\(--navbar-margin, 0px\); \}/) }) it('hides the horizontal navbar in the vertical position', () => { @@ -50,6 +50,14 @@ describe('navbar position', () => { it('comes after the folded sidebar rule, so the two agree on --sidebar-width', () => { expect(flat.indexOf('html[data-bs-sidebar^=folded]')).toBeLessThan(flat.indexOf('html:not([data-bs-navbar-position=vertical])')) }) + + it('offsets the two-row navbar menu row, not just its header, next to the sidebar', () => { + expect(flat).toContain('.navbar-expand.navbar-vertical ~ .navbar, .navbar-expand.navbar-vertical ~ [class*=navbar-expand]:not(.navbar-vertical), .navbar-expand.navbar-vertical ~ .page-wrapper { margin-inline-start: calc(var(--sidebar-width) + var(--sidebar-gap, 0px)); }') + }) + + it('gives the folded class route its own --sidebar-width copy on the menu row too', () => { + expect(flat).toContain('.navbar-vertical:is(.navbar-folded, .navbar-folded-hover) ~ .page, .navbar-vertical:is(.navbar-folded, .navbar-folded-hover) ~ .page-wrapper, .navbar-vertical:is(.navbar-folded, .navbar-folded-hover) ~ .navbar, .navbar-vertical:is(.navbar-folded, .navbar-folded-hover) ~ [class*=navbar-expand]:not(.navbar-vertical) { --sidebar-width: var(--sidebar-folded-width); }') + }) }) describe('navbar theme', () => { diff --git a/core/scss/ui/_badges.scss b/core/scss/ui/_badges.scss index 5c141c626..6869c2fa6 100644 --- a/core/scss/ui/_badges.scss +++ b/core/scss/ui/_badges.scss @@ -44,7 +44,7 @@ $badge-tokens: defaults( @include border-radius(var(--badge-border-radius)); @at-root a#{&} { - background: var(--bg-surface-secondary); + background: var(--fill-secondary); } .icon { diff --git a/core/scss/ui/_buttons.scss b/core/scss/ui/_buttons.scss index d8f4d8c64..b458a4b74 100644 --- a/core/scss/ui/_buttons.scss +++ b/core/scss/ui/_buttons.scss @@ -16,10 +16,10 @@ $btn-tokens: defaults( --btn-bg: var(--bg-surface), --btn-color: var(--body-color), --btn-border-color: $btn-border-color, - --btn-hover-bg: var(--hover-bg), + --btn-hover-bg: $btn-hover-bg, --btn-hover-border-color: var(--border-active-color), --btn-active-color: $active-color, - --btn-active-bg: var(--active-bg), + --btn-active-bg: $btn-active-bg, --btn-active-border-color: $active-border-color, ), $btn-tokens @@ -132,6 +132,7 @@ $btn-tokens: defaults( --btn-padding-y: #{$input-btn-padding-y-xl}; --btn-padding-x: #{$input-btn-padding-x-xl}; --btn-font-size: #{$input-btn-font-size-xl}; + --btn-border-radius: #{$btn-border-radius-xl}; } // diff --git a/core/scss/ui/_cards.scss b/core/scss/ui/_cards.scss index f9e9cf36b..91391fc0f 100644 --- a/core/scss/ui/_cards.scss +++ b/core/scss/ui/_cards.scss @@ -496,6 +496,8 @@ $card-tokens: defaults( // Card table .card-table { + --card-table-top-radius: 0; + --card-table-bottom-radius: 0; margin-bottom: 0 !important; tr { @@ -504,13 +506,11 @@ $card-tokens: defaults( &:first-child { padding-inline-start: var(--card-spacer-x); border-inline-start: 0; - border-start-start-radius: var(--card-border-radius); } &:last-child { padding-inline-end: var(--card-spacer-x); border-inline-end: 0; - border-start-end-radius: var(--card-border-radius); } } } @@ -518,14 +518,26 @@ $card-tokens: defaults( thead, tbody, tfoot { + &:first-child { + tr:first-child { + > *:first-child { + border-start-start-radius: var(--card-table-top-radius); + } + + > *:last-child { + border-start-end-radius: var(--card-table-top-radius); + } + } + } + &:last-child { tr:last-child { > *:last-child { - border-end-end-radius: calc(var(--card-border-radius) - var(--card-border-width)); + border-end-end-radius: var(--card-table-bottom-radius); } > *:first-child { - border-end-start-radius: calc(var(--card-border-radius) - var(--card-border-width)); + border-end-start-radius: var(--card-table-bottom-radius); } } } @@ -570,6 +582,16 @@ $card-tokens: defaults( } } +.card > .card-table:first-child, +.card > .table-responsive:first-child > .card-table { + --card-table-top-radius: var(--card-border-radius); +} + +.card > .card-table:last-child, +.card > .table-responsive:last-child > .card-table { + --card-table-bottom-radius: calc(var(--card-border-radius) - var(--card-border-width)); +} + /* Card code */ diff --git a/core/scss/ui/_chat.scss b/core/scss/ui/_chat.scss index 695e52844..20d0a33cc 100644 --- a/core/scss/ui/_chat.scss +++ b/core/scss/ui/_chat.scss @@ -29,7 +29,7 @@ $chat-tokens: defaults( .chat-bubble { position: relative; padding: var(--chat-bubble-padding); - background: var(--bg-surface-secondary); + background: var(--fill-tertiary); @include border-radius(var(--border-radius-lg)); } diff --git a/core/scss/ui/_datagrid.scss b/core/scss/ui/_datagrid.scss index 18b2b1e09..daf0c0e56 100644 --- a/core/scss/ui/_datagrid.scss +++ b/core/scss/ui/_datagrid.scss @@ -13,6 +13,7 @@ $datagrid-tokens: defaults( --datagrid-padding: $datagrid-padding, --datagrid-item-width: $datagrid-item-width, --datagrid-title-margin-bottom: $datagrid-title-margin-bottom, + --datagrid-title-width: $datagrid-title-width, ), $datagrid-tokens ); @@ -31,3 +32,26 @@ $datagrid-tokens: defaults( @include subheader(); margin-bottom: var(--datagrid-title-margin-bottom); } + +.datagrid-content { + margin: 0; +} + +.datagrid-horizontal { + --datagrid-title-margin-bottom: 0; + + grid-template-columns: minmax(0, 1fr); + + .datagrid-item { + display: grid; + grid-template-columns: var(--datagrid-title-width) 1fr; + gap: 1rem; + align-items: baseline; + } +} + +@for $i from 1 through $datagrid-columns { + .datagrid-cols-#{$i} { + grid-template-columns: repeat(#{$i}, minmax(0, 1fr)); + } +} diff --git a/core/scss/ui/_flags.scss b/core/scss/ui/_flags.scss index 1d38ce692..62356b6a7 100644 --- a/core/scss/ui/_flags.scss +++ b/core/scss/ui/_flags.scss @@ -7,7 +7,7 @@ position: relative; display: inline-block; width: config.$avatar-size; - aspect-ratio: 1.33333; + aspect-ratio: 1.25; vertical-align: bottom; background: no-repeat center/cover; box-shadow: config.$flag-box-shadow; @@ -25,6 +25,16 @@ } } +// deprecated(2.0): the old flag codes keep working, each with its own copy +// of the image, see `$flag-country-aliases` +@if config.$enable-deprecated { + @each $old, $new in config.$flag-country-aliases { + .flag-country-#{$old} { + background-image: url('#{config.$assets-base}/img/flags/#{$old}.svg'); + } + } +} + @each $flag-size, $size in config.$flag-sizes { .flag-#{$flag-size} { width: map.get($size, size); diff --git a/core/scss/ui/_forms.scss b/core/scss/ui/_forms.scss index c3b4e78eb..c04a94964 100644 --- a/core/scss/ui/_forms.scss +++ b/core/scss/ui/_forms.scss @@ -135,7 +135,7 @@ fieldset:empty { text-decoration: none; cursor: pointer; user-select: none; - background: var(--gray-100); + background: var(--bg-surface-tertiary); border: 0; @include border-radius(var(--border-radius-pill)); @include transition(background-color $transition-time, color $transition-time); diff --git a/core/scss/ui/_hover-gallery.scss b/core/scss/ui/_hover-gallery.scss new file mode 100644 index 000000000..dabb6b3fa --- /dev/null +++ b/core/scss/ui/_hover-gallery.scss @@ -0,0 +1,118 @@ +@use '../config' as *; + +// Every item is an equal, invisible column; hovering a column shows its image over the whole gallery +.hover-gallery { + // scss-docs-start hover-gallery-css-vars + --hover-gallery-aspect-ratio: #{$hover-gallery-aspect-ratio}; + --hover-gallery-indicator-size: #{$hover-gallery-indicator-size}; + --hover-gallery-indicator-gap: #{$hover-gallery-indicator-gap}; + --hover-gallery-indicator-padding: #{$hover-gallery-indicator-padding}; + --hover-gallery-indicator-color: #{$hover-gallery-indicator-color}; + --hover-gallery-indicator-scrim-height: #{$hover-gallery-indicator-scrim-height}; + --hover-gallery-indicator-scrim-bg: #{$hover-gallery-indicator-scrim-bg}; + --hover-gallery-indicator-inactive-opacity: #{$hover-gallery-indicator-inactive-opacity}; + // scss-docs-end hover-gallery-css-vars + position: relative; + display: flex; + aspect-ratio: var(--hover-gallery-aspect-ratio); + overflow: hidden; +} + +.hover-gallery-item { + flex: 1; + + img { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + pointer-events: none; + object-fit: cover; + opacity: 0; + } + + &:first-child img, + &:hover img { + opacity: 1; + } + + &:hover img { + z-index: 1; + } +} + +// One bar per item at the bottom edge, lit for the image on display +.hover-gallery-indicators { + gap: var(--hover-gallery-indicator-gap); + padding: var(--hover-gallery-indicator-padding); + + // Darkens the bottom edge so the bars stay visible on any image + &::after { + position: absolute; + inset: auto 0 0; + z-index: 1; + height: var(--hover-gallery-indicator-scrim-height); + pointer-events: none; + content: ''; + background: var(--hover-gallery-indicator-scrim-bg); + } + + .hover-gallery-item { + display: flex; + align-items: flex-end; + + // The gap between columns must not drop the hover, so the item reaches over it + padding-inline: calc(var(--hover-gallery-indicator-gap) * 0.5); + margin-inline: calc(var(--hover-gallery-indicator-gap) * -0.5); + + &::after { + position: relative; + z-index: 2; + flex: 1; + height: var(--hover-gallery-indicator-size); + content: ''; + background: var(--hover-gallery-indicator-color); + opacity: var(--hover-gallery-indicator-inactive-opacity); + @include border-radius(var(--hover-gallery-indicator-size)); + } + + &:hover::after { + opacity: 1; + } + } + + &:not(:hover) .hover-gallery-item:first-child::after { + opacity: 1; + } +} + +// Without hover the same markup becomes a swipeable strip +@media (hover: none) { + .hover-gallery { + gap: 0; + padding: 0; + overflow-x: auto; + scroll-snap-type: x mandatory; + scrollbar-width: none; + } + + .hover-gallery::after { + display: none; + } + + .hover-gallery .hover-gallery-item { + flex: 0 0 100%; + padding: 0; + margin: 0; + scroll-snap-align: start; + + img { + position: static; + opacity: 1; + } + + &::after { + display: none; + } + } +} diff --git a/core/scss/ui/_images.scss b/core/scss/ui/_images.scss index 4a62d2b5d..a376acd00 100644 --- a/core/scss/ui/_images.scss +++ b/core/scss/ui/_images.scss @@ -21,3 +21,64 @@ .img-bg { background: no-repeat center/cover; } + +// A label pinned to a corner of an image, such as "AI", "New" or a video length +.img-labeled { + position: relative; + display: inline-block; + max-width: 100%; + vertical-align: middle; +} + +.img-label { + // scss-docs-start img-label-css-vars + --img-label-offset: #{$img-label-offset}; + --img-label-padding-y: #{$img-label-padding-y}; + --img-label-padding-x: #{$img-label-padding-x}; + --img-label-gap: #{$img-label-gap}; + --img-label-font-size: #{$img-label-font-size}; + --img-label-font-weight: #{$img-label-font-weight}; + --img-label-color: #{$img-label-color}; + --img-label-bg: #{$img-label-bg}; + --img-label-border-radius: #{$img-label-border-radius}; + --img-label-backdrop-filter: #{$img-label-backdrop-filter}; + --img-label-icon-size: #{$img-label-icon-size}; + // scss-docs-end img-label-css-vars + position: absolute; + inset-block-end: var(--img-label-offset); + inset-inline-end: var(--img-label-offset); + z-index: 1; + display: inline-flex; + gap: var(--img-label-gap); + align-items: center; + max-width: calc(100% - 2 * var(--img-label-offset)); + padding: var(--img-label-padding-y) var(--img-label-padding-x); + font-size: var(--img-label-font-size); + font-weight: var(--img-label-font-weight); + line-height: 1; + color: var(--img-label-color); + white-space: nowrap; + pointer-events: none; + background: var(--img-label-bg); + backdrop-filter: var(--img-label-backdrop-filter); + @include border-radius(var(--img-label-border-radius)); + + .icon { + --icon-size: var(--img-label-icon-size); + } +} + +// A label that holds only an icon: equal padding on all sides makes it a square +.img-label-icon { + --img-label-padding-x: var(--img-label-padding-y); +} + +.img-label-top-start, +.img-label-top-end { + inset-block: var(--img-label-offset) auto; +} + +.img-label-top-start, +.img-label-bottom-start { + inset-inline: var(--img-label-offset) auto; +} diff --git a/core/scss/ui/_index.scss b/core/scss/ui/_index.scss index 694f7af1b..b23435d16 100644 --- a/core/scss/ui/_index.scss +++ b/core/scss/ui/_index.scss @@ -21,6 +21,7 @@ @forward 'datagrid'; @forward 'empty'; @forward 'grid'; +@forward 'hover-gallery'; @forward 'icons'; @forward 'images'; @forward 'forms'; @@ -31,17 +32,22 @@ @forward 'forms/form-custom'; @forward 'forms/form-check'; @forward 'forms/form-otp'; +@forward 'forms/form-number-input'; @forward 'forms/validation'; @forward 'forms/strength'; @forward 'legend'; @forward 'lists'; @forward 'loaders'; @forward 'login'; +@forward 'mockup-browser'; @forward 'modals'; @forward 'nav'; +@forward 'nav-overflow'; @forward 'stars'; @forward 'pagination'; @forward 'popovers'; +@forward 'pricing'; +@forward 'shape'; @forward 'progress'; @forward 'ribbons'; @forward 'markdown'; @@ -53,13 +59,16 @@ @forward 'switch-icon'; @forward 'tables'; @forward 'tags'; +@forward 'text-shimmer'; @forward 'toasts'; @forward 'toolbar'; @forward 'tracking'; @forward 'timeline'; @forward 'type'; +@forward 'kbd'; @forward 'charts'; @forward 'sparkline'; +@forward 'vector-map'; @forward 'offcanvas'; @forward 'chat'; @forward 'signature'; diff --git a/core/scss/ui/_kbd.scss b/core/scss/ui/_kbd.scss new file mode 100644 index 000000000..b72d50e77 --- /dev/null +++ b/core/scss/ui/_kbd.scss @@ -0,0 +1,83 @@ +// +// Keyboard key +// +@use '../config' as *; + +kbd, +.kbd { + // scss-docs-start kbd-css-vars + --kbd-height: #{$kbd-height}; + --kbd-padding-x: #{$kbd-padding-x}; + --kbd-padding-y: #{$kbd-padding-y}; + --kbd-gap: #{$kbd-gap}; + --kbd-font-size: #{$kbd-font-size}; + --kbd-font-weight: #{$kbd-font-weight}; + --kbd-color: #{$kbd-color}; + --kbd-bg: #{$kbd-bg}; + --kbd-border: #{$kbd-border}; + --kbd-border-radius: #{$kbd-border-radius}; + // scss-docs-end kbd-css-vars + box-sizing: border-box; + display: inline-flex; + gap: var(--kbd-gap); + align-items: center; + justify-content: center; + min-width: var(--kbd-height); + max-width: 100%; + height: var(--kbd-height); + padding: var(--kbd-padding-y) var(--kbd-padding-x); + font-family: var(--font-sans-serif); + font-size: var(--kbd-font-size); + font-weight: var(--kbd-font-weight); + line-height: 1; + color: var(--kbd-color); + white-space: nowrap; + vertical-align: baseline; + background-color: var(--kbd-bg); + border: var(--kbd-border); + @include border-radius(var(--kbd-border-radius)); + + .icon { + --icon-size: 0.75rem; + } +} + +// On a coloured surface the key takes the colour of the text around it +.btn, +.tooltip, +.badge { + kbd, + .kbd { + --kbd-color: inherit; + } +} + +.btn, +.badge { + > kbd, + > .kbd { + margin-inline-start: 0.5em; + } +} + +// +// Group of keys +// +.kbd-group { + display: inline-flex; + gap: var(--kbd-gap, #{$kbd-gap}); + align-items: center; + min-width: 0; + height: auto; + padding: 0; + font-weight: inherit; + color: inherit; + background: transparent; + border: 0; + + // Reboot removes the padding of a nested `kbd` + > kbd { + padding: var(--kbd-padding-y) var(--kbd-padding-x); + font-size: var(--kbd-font-size); + } +} diff --git a/core/scss/ui/_lists.scss b/core/scss/ui/_lists.scss index 65f869515..7fab49dbd 100644 --- a/core/scss/ui/_lists.scss +++ b/core/scss/ui/_lists.scss @@ -2,6 +2,8 @@ .list-group { --list-group-header-padding-y: #{$list-group-header-padding-y}; + --list-group-item-gap: #{$list-group-item-gap}; + --list-group-nested-indent: #{$list-group-nested-indent}; margin-inline-start: 0; margin-inline-end: 0; @@ -35,10 +37,98 @@ } } -.list-group-item.active { +// A checked `.list-group-selectable-check` marks its item like `.active`, +// the same way `.table-selectable` marks a table row. +.list-group-item.active, +.list-group-selectable .list-group-item:has(.list-group-selectable-check:checked) { background-color: var(--active-bg); - border-inline-start-color: var(--primary); - border-inline-start-width: $border-width-wide; + // An inset shadow instead of a wider border, so the content does not shift + box-shadow: inset $border-width-wide 0 0 var(--primary); +} + +// Density +.list-group-sm { + --list-group-item-padding-y: #{$list-group-item-padding-y-sm}; + --list-group-item-gap: #{$list-group-item-gap-sm}; +} + +.list-group-lg { + --list-group-item-padding-y: #{$list-group-item-padding-y-lg}; + --list-group-item-gap: #{$list-group-item-gap-lg}; +} + +// Item slots: media, content (title + description), meta and actions. +// The item becomes a flex row only when it holds a content slot, so items +// with plain text or a grid row inside keep their layout. +.list-group-item:has(> .list-group-item-content) { + display: flex; + gap: var(--list-group-item-gap); + align-items: center; +} + +.list-group-item-media, +.list-group-item-meta, +.list-group-item-actions { + flex: none; +} + +.list-group-item-media { + display: flex; + align-items: center; +} + +.list-group-item-content { + flex: 1; + min-width: 0; +} + +.list-group-item-title, +.list-group-item-description { + display: block; + @include text-truncate(); +} + +.list-group-item-title { + color: inherit; +} + +.list-group-item-description, +.list-group-item-meta { + color: var(--secondary); +} + +// Nested items: a block of indented items under a parent item, usually a +// `.collapse` toggled by that parent. It draws the side and bottom borders its +// borderless items leave out. +.list-group-nested { + border: var(--list-group-border-width) solid var(--list-group-border-color); + border-top-width: 0; + + > .list-group-item { + padding-inline-start: calc(var(--list-group-item-padding-x) + var(--list-group-nested-indent)); + border-width: 0; + } + + + .list-group-item { + border-top-width: 0; + } + + .list-group-flush > & { + border-inline-width: 0; + + &:last-child { + border-bottom-width: 0; + } + } +} + +// Chevron of a parent item, turned over while its nested items are open +.list-group-item-toggle { + @include transition(transform $transition-time); + + [aria-expanded='true'] & { + transform: rotate(180deg); + } } .list-bordered { @@ -114,3 +204,42 @@ content: ' · '; } } + +// Icon list: a list whose markers are icons instead of bullets, such as a +// checklist of features. The first `.icon` in an entry is the marker. +.list-icon { + // scss-docs-start list-icon-css-vars + --list-icon-gap: #{$list-icon-gap}; + --list-icon-row-gap: #{$list-icon-row-gap}; + --list-icon-inline-gap: #{$list-icon-inline-gap}; + --list-icon-size: #{$list-icon-size}; + --list-icon-color: #{$list-icon-color}; + // scss-docs-end list-icon-css-vars + display: flex; + flex-direction: column; + gap: var(--list-icon-row-gap); + padding-inline-start: 0; + list-style: none; + + > * { + display: flex; + gap: var(--list-icon-gap); + align-items: flex-start; + } + + > * > .icon:first-child { + --icon-size: var(--list-icon-size); + flex-shrink: 0; + // center the marker on the first line of text: `.icon` sets its own font + // size, which would make `1lh` the icon's line height, so inherit the text's + margin-top: calc((1lh - var(--list-icon-size)) / 2); + font-size: inherit; + color: var(--list-icon-color); + } +} + +// Entries in a row that wraps, for a short list of highlights +.list-icon-inline { + flex-flow: row wrap; + gap: var(--list-icon-row-gap) var(--list-icon-inline-gap); +} diff --git a/core/scss/ui/_mockup-browser.scss b/core/scss/ui/_mockup-browser.scss new file mode 100644 index 000000000..c89cee697 --- /dev/null +++ b/core/scss/ui/_mockup-browser.scss @@ -0,0 +1,111 @@ +@use 'sass:list'; +@use '../config' as *; + +// A frame that looks like a browser window, to show a page preview or a screenshot +.mockup-browser { + // scss-docs-start mockup-browser-css-vars + --mockup-browser-bg: #{$mockup-browser-bg}; + --mockup-browser-border-color: #{$mockup-browser-border-color}; + --mockup-browser-border-radius: #{$mockup-browser-border-radius}; + --mockup-browser-box-shadow: #{$mockup-browser-box-shadow}; + --mockup-browser-toolbar-min-height: #{$mockup-browser-toolbar-min-height}; + --mockup-browser-toolbar-padding-y: #{$mockup-browser-toolbar-padding-y}; + --mockup-browser-toolbar-padding-x: #{$mockup-browser-toolbar-padding-x}; + --mockup-browser-toolbar-color: #{$mockup-browser-toolbar-color}; + --mockup-browser-toolbar-bg: #{$mockup-browser-toolbar-bg}; + --mockup-browser-control-offset: #{$mockup-browser-control-offset}; + --mockup-browser-control-size: #{$mockup-browser-control-size}; + --mockup-browser-control-gap: #{$mockup-browser-control-gap}; + --mockup-browser-control-color-1: #{list.nth($mockup-browser-control-colors, 1)}; + --mockup-browser-control-color-2: #{list.nth($mockup-browser-control-colors, 2)}; + --mockup-browser-control-color-3: #{list.nth($mockup-browser-control-colors, 3)}; + --mockup-browser-url-font-size: #{$mockup-browser-url-font-size}; + --mockup-browser-url-line-height: #{$mockup-browser-url-line-height}; + --mockup-browser-url-icon-size: #{$mockup-browser-url-icon-size}; + // scss-docs-end mockup-browser-css-vars + display: flex; + flex-direction: column; + overflow: hidden; + background: var(--mockup-browser-bg); + border: var(--border-width) solid var(--mockup-browser-border-color); + box-shadow: var(--mockup-browser-box-shadow); + @include border-radius(var(--mockup-browser-border-radius)); +} + +// Small: a 24px toolbar for thumbnails and cards +.mockup-browser-sm { + --mockup-browser-border-radius: #{$mockup-browser-sm-border-radius}; + --mockup-browser-toolbar-min-height: #{$mockup-browser-sm-toolbar-min-height}; + --mockup-browser-toolbar-padding-y: #{$mockup-browser-sm-toolbar-padding-y}; + --mockup-browser-control-size: #{$mockup-browser-sm-control-size}; + --mockup-browser-control-gap: #{$mockup-browser-sm-control-gap}; + --mockup-browser-url-font-size: #{$mockup-browser-sm-url-font-size}; + --mockup-browser-url-icon-size: #{$mockup-browser-sm-url-icon-size}; +} + +// The top bar: the window dots sit at the start, the address bar fills the middle +.mockup-browser-toolbar { + position: relative; + display: flex; + align-items: center; + min-height: var(--mockup-browser-toolbar-min-height); + padding: var(--mockup-browser-toolbar-padding-y) var(--mockup-browser-toolbar-padding-x); + color: var(--mockup-browser-toolbar-color); + background: var(--mockup-browser-toolbar-bg); + // Drawn as an inset shadow so the line does not add to the toolbar height + box-shadow: inset 0 calc(-1 * var(--border-width)) var(--mockup-browser-border-color); +} + +// The three window dots, drawn with one element and two shadows. They are taken +// out of the flow so the address bar stays centered whatever the frame width +.mockup-browser-controls { + position: absolute; + inset-inline-start: var(--mockup-browser-control-offset); + width: var(--mockup-browser-control-size); + height: var(--mockup-browser-control-size); + background: var(--mockup-browser-control-color-1); + border-radius: 50%; + box-shadow: + calc(var(--mockup-browser-control-size) + var(--mockup-browser-control-gap)) 0 0 var(--mockup-browser-control-color-2), + calc(2 * (var(--mockup-browser-control-size) + var(--mockup-browser-control-gap))) 0 0 var(--mockup-browser-control-color-3); +} + +// The address, plain centered text +.mockup-browser-url { + display: flex; + gap: 0.25rem; + align-items: center; + justify-content: center; + width: 100%; + min-width: 0; + font-size: var(--mockup-browser-url-font-size); + line-height: var(--mockup-browser-url-line-height); + + > span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .icon { + --icon-size: var(--mockup-browser-url-icon-size); + flex-shrink: 0; + } +} + +// The page: a screenshot fills the width, other content keeps its own layout +.mockup-browser-body { + flex: 1; + min-height: 0; + + > img, + > iframe, + > video { + display: block; + width: 100%; + } + + > iframe { + border: 0; + } +} diff --git a/core/scss/ui/_nav-overflow.scss b/core/scss/ui/_nav-overflow.scss new file mode 100644 index 000000000..ea873e3b3 --- /dev/null +++ b/core/scss/ui/_nav-overflow.scss @@ -0,0 +1,41 @@ +@use '../config' as *; + +// The wrapper is measured, so it must never size to the nav inside it +.nav-overflow { + contain: inline-size; + flex: 1 1 0; + min-width: 0; + + // Clip instead of hiding, so the nav stays usable when the script does not run + &:not(.nav-overflow-initialized) { + overflow: clip; + } + + > .nav, + > .navbar-nav { + display: flex; + flex-wrap: nowrap; + min-width: 0; + + > .nav-item { + flex-shrink: 0; + } + } +} + +.nav-overflow-item { + margin-inline-start: auto; +} + +.nav-overflow-toggle { + gap: var(--nav-link-icon-margin-end); +} + +.nav-overflow-icon { + display: flex; + + .icon { + width: var(--nav-link-icon-width, #{$nav-link-icon-size}); + height: var(--nav-link-icon-height, #{$nav-link-icon-size}); + } +} diff --git a/core/scss/marketing/_pricing.scss b/core/scss/ui/_pricing.scss similarity index 97% rename from core/scss/marketing/_pricing.scss rename to core/scss/ui/_pricing.scss index 80259cef6..4961d2e21 100644 --- a/core/scss/marketing/_pricing.scss +++ b/core/scss/ui/_pricing.scss @@ -22,7 +22,7 @@ $pricing-card-width: 22rem; margin: 0 0 1rem; text-align: center; background: var(--bg-surface); - border: 1px solid $border-color; + border: var(--border-width) solid var(--border-color); box-shadow: var(--shadow-card); @include border-radius($border-radius-lg); diff --git a/core/scss/ui/_segmented.scss b/core/scss/ui/_segmented.scss index a45fa8a44..9da04e04c 100644 --- a/core/scss/ui/_segmented.scss +++ b/core/scss/ui/_segmented.scss @@ -18,6 +18,7 @@ $nav-segmented-tokens: defaults( --nav-link-padding-x: $nav-segmented-link-padding-x, --nav-link-icon-size: $nav-segmented-link-icon-size, --nav-link-icon-margin: $nav-segmented-icon-margin, + --nav-link-hover-icon-color: currentColor, ), $nav-segmented-tokens ); diff --git a/core/scss/marketing/_shape.scss b/core/scss/ui/_shape.scss similarity index 91% rename from core/scss/marketing/_shape.scss rename to core/scss/ui/_shape.scss index b09854eff..6d7e94141 100644 --- a/core/scss/marketing/_shape.scss +++ b/core/scss/ui/_shape.scss @@ -3,9 +3,11 @@ @use '../config' as *; .shape { + // scss-docs-start shape-css-vars --shape-size: #{$avatar-size}; --shape-icon-size: #{$avatar-icon-size}; --shape-border-radius: 35%; + // scss-docs-end shape-css-vars display: inline-flex; align-items: center; justify-content: center; diff --git a/core/scss/ui/_text-shimmer.scss b/core/scss/ui/_text-shimmer.scss new file mode 100644 index 000000000..ad035353e --- /dev/null +++ b/core/scss/ui/_text-shimmer.scss @@ -0,0 +1,92 @@ +@use '../config' as *; + +// A highlight that sweeps across the text, for "Thinking…" or "Generating…" labels +.text-shimmer { + // scss-docs-start text-shimmer-css-vars + --text-shimmer-duration: #{$text-shimmer-duration}; + --text-shimmer-opacity: #{$text-shimmer-opacity}; + --text-shimmer-width: #{$text-shimmer-width}; + --text-shimmer-fade-duration: #{$text-shimmer-fade-duration}; + --text-shimmer-fade-easing: #{$text-shimmer-fade-easing}; + --text-shimmer-loader-size: #{$text-shimmer-loader-size}; + --text-shimmer-loader-border-width: #{$text-shimmer-loader-border-width}; + --text-shimmer-loader-margin: #{$text-shimmer-loader-margin}; + --text-shimmer-loader-duration: #{$text-shimmer-loader-duration}; + --text-shimmer-loader-opacity: #{$text-shimmer-loader-opacity}; + // scss-docs-end text-shimmer-css-vars + display: inline-block; + // The mask dims the text and lets a band of full opacity move through it, so the text keeps its own color + mask-image: linear-gradient(90deg, rgba(0, 0, 0, var(--text-shimmer-opacity)) calc(50% - var(--text-shimmer-width) / 2), $black 50%, rgba(0, 0, 0, var(--text-shimmer-opacity)) calc(50% + var(--text-shimmer-width) / 2)); + mask-size: 300% 100%; + // The label fades in once, then the highlight keeps sweeping + animation: + text-shimmer-in var(--text-shimmer-fade-duration) var(--text-shimmer-fade-easing) both, + text-shimmer var(--text-shimmer-duration) linear infinite; + + @media (prefers-reduced-motion: reduce) { + mask-image: none; + animation: none; + } +} + +// A small spinner before the label: the element is a 1em box, ::before draws +// a faint ring in the text color and ::after the bright arc that turns on top +// of it. It sits inside the mask, so it dims and lights up with the text +.text-shimmer-loader { + position: relative; + display: inline-block; + width: 1em; + height: 1em; + margin-inline-end: var(--text-shimmer-loader-margin); + vertical-align: -0.125em; + + // The ring is --text-shimmer-loader-size wide, centered in the 1em box + &::before, + &::after { + position: absolute; + inset: 0; + width: var(--text-shimmer-loader-size); + height: var(--text-shimmer-loader-size); + margin: auto; + content: ''; + border: var(--text-shimmer-loader-border-width) solid; + border-radius: 50%; + } + + &::before { + border-color: currentColor; + opacity: var(--text-shimmer-loader-opacity); + } + + &::after { + border-color: currentColor transparent transparent; + animation: rotate-360 var(--text-shimmer-loader-duration) linear infinite; + + @media (prefers-reduced-motion: reduce) { + animation-duration: calc(var(--text-shimmer-loader-duration) * 2); + } + } +} + +@keyframes text-shimmer-in { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +// The highlight travels in the reading direction: --dir flips the start and end in RTL +@keyframes text-shimmer { + from { + /* rtl:ignore */ + mask-position: calc(50% + var(--dir) * 50%) 0; + } + + to { + /* rtl:ignore */ + mask-position: calc(50% - var(--dir) * 50%) 0; + } +} diff --git a/core/scss/ui/_type.scss b/core/scss/ui/_type.scss index 3adb402b2..f7b4e7b88 100644 --- a/core/scss/ui/_type.scss +++ b/core/scss/ui/_type.scss @@ -162,19 +162,6 @@ abbr { text-decoration-skip-ink: none; } -kbd, -.kbd { - box-sizing: border-box; - display: inline-block; - max-width: 100%; - font-size: $kbd-font-size; - font-weight: $kbd-font-weight; - line-height: 1; - vertical-align: baseline; - border: $kbd-border; - @include border-radius($kbd-border-radius); -} - img { max-width: 100%; height: auto; diff --git a/core/scss/ui/_vector-map.scss b/core/scss/ui/_vector-map.scss new file mode 100644 index 000000000..2470ce95e --- /dev/null +++ b/core/scss/ui/_vector-map.scss @@ -0,0 +1,217 @@ +// Vector map (inline SVG, rendered by js/src/vector-map.ts) +@use '../config' as *; + +@keyframes vector-map-line { + to { + stroke-dashoffset: calc(var(--vector-map-line-dash) * -2); + } +} + +.vector-map { + // scss-docs-start vector-map-css-vars + --vector-map-color: #{$vector-map-color}; + --vector-map-region-fill: #{$vector-map-region-fill}; + --vector-map-region-stroke: #{$vector-map-region-stroke}; + --vector-map-region-stroke-width: #{$vector-map-region-stroke-width}; + --vector-map-value-min: #{$vector-map-value-min}; + --vector-map-point-size: #{$vector-map-point-size}; + --vector-map-selected-stroke: #{$vector-map-selected-stroke}; + --vector-map-selected-stroke-width: #{$vector-map-selected-stroke-width}; + --vector-map-marker-size: #{$vector-map-marker-size}; + --vector-map-marker-color: #{$vector-map-marker-color}; + --vector-map-marker-halo-color: #{$vector-map-marker-halo-color}; + --vector-map-marker-halo-width: #{$vector-map-marker-halo-width}; + --vector-map-line-color: #{$vector-map-line-color}; + --vector-map-line-width: #{$vector-map-line-width}; + --vector-map-line-dash: #{$vector-map-line-dash}; + --vector-map-line-duration: #{$vector-map-line-duration}; + --vector-map-controls-offset: #{$vector-map-controls-offset}; + --vector-map-legend-width: #{$vector-map-legend-width}; + --vector-map-legend-height: #{$vector-map-legend-height}; + // scss-docs-end vector-map-css-vars + + position: relative; + display: block; + line-height: 0; +} + +.vector-map-svg { + display: block; + width: 100%; + height: 100%; +} + +.vector-map-region { + // The colour of the region, used as the fill of a shape and as the dot of a + // region drawn as a point. + --vector-map-region-color: var(--vector-map-region-fill); + fill: var(--vector-map-region-color); + stroke: var(--vector-map-region-stroke); + stroke-linejoin: round; + stroke-width: var(--vector-map-region-stroke-width); + // The border keeps its width however large the map is drawn + vector-effect: non-scaling-stroke; +} + +// `--vector-map-value` is the 0 to 1 share set by the plugin. The lowest value +// still gets `--vector-map-value-min` of the colour, so it stands out from a +// region with no value at all. A slice of the legend has a share too, and +// gets its colour from the same rule. +.vector-map-region[data-value], +.vector-map-legend-step { + --vector-map-region-color: color-mix(in oklab, var(--vector-map-color) calc((var(--vector-map-value-min) + var(--vector-map-value, 1) * (1 - var(--vector-map-value-min))) * 100%), var(--vector-map-region-fill)); +} + +// Dashes that move from the start of the line to its end +.vector-map-line { + fill: none; + stroke: var(--vector-map-line-color); + stroke-dasharray: var(--vector-map-line-dash); + stroke-width: var(--vector-map-line-width); + vector-effect: non-scaling-stroke; + animation: vector-map-line var(--vector-map-line-duration) linear infinite; + + @media (prefers-reduced-motion: reduce) { + animation: none; + } +} + +// Both parts of a marker, and of a region drawn as a point, are a line of no +// length: its round caps make a dot as wide as the stroke, and the non-scaling +// stroke keeps it that wide in pixels however large the map is drawn. +.vector-map-region-point-halo, +.vector-map-region-point-dot, +.vector-map-marker-halo, +.vector-map-marker-dot { + fill: none; + stroke-linecap: round; + vector-effect: non-scaling-stroke; +} + +// A scale of several colours, from `--vector-map-color-1` for the lowest value +// to the last one for the highest. `--vector-map-colors` says how many there +// are. Every further colour is mixed over the result so far: not at all below +// its part of the scale, more and more inside it, and fully above it, so each +// part runs between two neighbours. A colour past the last one never gets a +// share, which is why one rule serves any number of them. +.vector-map-colors .vector-map-region[data-value], +.vector-map-colors .vector-map-legend-step { + $color: var(--vector-map-color-1, transparent); + + @for $i from 2 through $vector-map-colors-max { + $color: color-mix(in oklab, #{$color}, var(--vector-map-color-#{$i}, transparent) calc(clamp(0, var(--vector-map-value, 1) * (var(--vector-map-colors, 2) - 1) - #{$i - 2}, 1) * 100%)); + } + + --vector-map-region-color: #{$color}; +} + +// A country too small for a shape: a dot in the colour of the region, with the +// ring of a marker around it. Without a value it says nothing and only adds +// noise next to the markers, so it is left out. +.vector-map-region-point:not([data-value]) { + display: none; +} + +.vector-map-region-point-halo { + stroke: var(--vector-map-marker-halo-color); + stroke-width: calc(var(--vector-map-point-size) + var(--vector-map-marker-halo-width) * 2); +} + +.vector-map-region-point-dot { + stroke: var(--vector-map-region-color); + stroke-width: var(--vector-map-point-size); +} + +.vector-map-marker-halo { + stroke: var(--vector-map-marker-halo-color); + stroke-width: calc(var(--vector-map-marker-size) + var(--vector-map-marker-halo-width) * 2); +} + +.vector-map-marker-dot { + stroke: var(--vector-map-marker-color); + stroke-width: var(--vector-map-marker-size); +} + +// Follows the pointer, placed by the plugin with `left` and `top` +.vector-map-tooltip { + position: absolute; + z-index: 1; + padding: $tooltip-padding-y $tooltip-padding-x; + font-size: $tooltip-font-size; + line-height: $line-height-base; + color: $tooltip-color; + white-space: nowrap; + pointer-events: none; + background: $tooltip-bg; + @include border-radius($tooltip-border-radius); + + /* rtl:ignore */ + transform: translate(-50%, calc(-100% - #{$spacer-2})); +} + +// A map that can be magnified takes the drags and pinches for itself, so the +// page does not scroll or zoom under the finger. +.vector-map-zoomable .vector-map-svg { + touch-action: none; + cursor: grab; + user-select: none; +} + +.vector-map-dragging .vector-map-svg { + cursor: grabbing; +} + +.vector-map-controls { + position: absolute; + inset-block-start: var(--vector-map-controls-offset); + inset-inline-end: var(--vector-map-controls-offset); + z-index: 1; + + .icon { + fill: none; + stroke: currentColor; + stroke-linecap: round; + stroke-width: 2; + } +} + +// The scale of the map as a bar, with the lowest and the highest value +.vector-map-legend { + display: flex; + gap: $spacer-2; + align-items: center; + justify-content: center; + margin-top: $spacer-2; + font-size: $font-size-sm; + font-variant-numeric: tabular-nums; + line-height: $line-height-base; + color: var(--secondary); +} + +.vector-map-legend-scale { + display: flex; + width: var(--vector-map-legend-width); + height: var(--vector-map-legend-height); + overflow: hidden; + @include border-radius($border-radius-pill); +} + +.vector-map-legend-step { + flex: 1; + background: var(--vector-map-region-color); +} + +// Regions can be clicked to select them +.vector-map-selectable .vector-map-region { + cursor: pointer; +} + +// A selected region gets an outline, and keeps the colour of its value +.vector-map-region-selected { + stroke: var(--vector-map-selected-stroke); + stroke-width: var(--vector-map-selected-stroke-width); + + .vector-map-region-point-halo { + stroke: var(--vector-map-selected-stroke); + } +} diff --git a/core/scss/ui/forms/_form-check.scss b/core/scss/ui/forms/_form-check.scss index ade6bbc3e..69be9e240 100644 --- a/core/scss/ui/forms/_form-check.scss +++ b/core/scss/ui/forms/_form-check.scss @@ -91,8 +91,8 @@ Form switch padding-inline-start: var(--form-switch-lg-padding-start); .form-check-input { - --form-switch-knob-size: calc(var(--form-switch-lg-height) * 0.75); - --form-switch-knob-offset: calc(var(--form-switch-lg-height) * 0.125); + --form-switch-knob-offset: 0.1875rem; + --form-switch-knob-size: calc(var(--form-switch-lg-height) - var(--form-switch-knob-offset) * 2); width: var(--form-switch-lg-width); height: var(--form-switch-lg-height); margin-inline-start: var(--form-switch-lg-input-offset); diff --git a/core/scss/ui/forms/_form-icon.scss b/core/scss/ui/forms/_form-icon.scss index 2e3b758b4..73c9acd78 100644 --- a/core/scss/ui/forms/_form-icon.scss +++ b/core/scss/ui/forms/_form-icon.scss @@ -4,6 +4,7 @@ .input-icon { --input-icon-padding-end: #{$input-icon-padding-end}; --input-icon-padding-start: #{$input-icon-padding-start}; + --input-icon-addon-height: #{input-height-token()}; position: relative; .form-control:not(:last-child), @@ -15,6 +16,14 @@ .form-select:not(:first-child) { padding-inline-start: var(--input-icon-padding-start); } + + &:has(> .form-control-sm, > .form-select-sm) { + --input-icon-addon-height: #{input-height-token('sm')}; + } + + &:has(> .form-control-lg, > .form-select-lg) { + --input-icon-addon-height: #{input-height-token('lg')}; + } } .input-icon-addon { @@ -28,7 +37,7 @@ align-items: center; justify-content: center; min-width: var(--input-icon-addon-width); - height: input-height-token(); + height: var(--input-icon-addon-height, #{input-height-token()}); font-size: 1.2em; color: var(--icon-color); pointer-events: none; diff --git a/core/scss/ui/forms/_form-number-input.scss b/core/scss/ui/forms/_form-number-input.scss new file mode 100644 index 000000000..145cb65d6 --- /dev/null +++ b/core/scss/ui/forms/_form-number-input.scss @@ -0,0 +1,164 @@ +/* +Number input (a field with a decrease and an increase button) + */ +@use 'sass:map'; +@use '../../config' as *; + +// One bordered box, like a `.form-control`: the field in the middle and the +// two buttons inside it have no border of their own. +.number-input { + // scss-docs-start number-input-css-vars + --number-input-input-width: #{$number-input-input-width}; + --number-input-bg: #{$number-input-bg}; + --number-input-border-color: #{$number-input-border-color}; + --number-input-border-radius: #{$number-input-border-radius}; + // scss-docs-end number-input-css-vars + position: relative; + display: inline-flex; + align-items: stretch; + // Never stretched by a flex column parent: the box is as wide as its parts + width: fit-content; + vertical-align: middle; + background-color: var(--number-input-bg); + border: $input-border-width solid var(--number-input-border-color); + box-shadow: $input-box-shadow; + @include border-radius(var(--number-input-border-radius)); + @include focus-ring-transition(border-color); + + // The box carries the focus ring for the field inside it. + &:focus-within { + --focus-ring-offset: -1px; + border-color: $input-focus-border-color; + @include focus-ring(true); + } + + &:has(> .form-control:disabled) { + background-color: $input-disabled-bg; + } + + > .form-control { + // stylelint-disable-next-line length-zero-no-unit + --control-border-width: 0px; + flex: 0 0 auto; + width: var(--number-input-input-width); + min-width: 0; + padding-inline: 0.25rem; + text-align: center; + appearance: textfield; + background-color: transparent; + border: 0; + border-radius: 0; + box-shadow: none; + + // A `type="number"` field keeps its keyboard behaviour but loses the + // native spin buttons, which the number input's own buttons replace. + &::-webkit-inner-spin-button, + &::-webkit-outer-spin-button { + margin: 0; + appearance: none; + } + + &:focus { + background-color: transparent; + outline: 0; + box-shadow: none; + } + + // The field is too narrow for the validation icon, which would sit on + // top of the number; the box border alone carries the state. + // `!important` because forms/_validation.scss forces the icon padding + // with one. + @each $state, $data in $form-validation-states { + &.is-#{$state}, + .was-validated &:#{$state} { + padding-inline-end: 0.25rem !important; + background-image: none; + } + } + } + + > .btn { + // With a unit, or `calc()` in the button's `min-width` has nothing to add + // stylelint-disable-next-line length-zero-no-unit + --btn-border-width: 0px; + --btn-bg: transparent; + --btn-border-color: transparent; + --btn-hover-border-color: transparent; + --btn-active-border-color: transparent; + --btn-hover-bg: var(--hover-bg); + --btn-box-shadow: none; + --focus-ring-offset: -2px; + // A held button must not select text or open the context menu on touch. + touch-action: manipulation; + user-select: none; + border-radius: 0; + + // An empty button paints the default icon through a CSS mask, so the + // markup needs no svg; any content put inside replaces it. + &[data-bs-number-input-action='decrement'], + &[data-tblr-number-input-action='decrement'] { + --number-input-icon: #{escape-svg($number-input-icon-decrement)}; + } + + &[data-bs-number-input-action='increment'], + &[data-tblr-number-input-action='increment'] { + --number-input-icon: #{escape-svg($number-input-icon-increment)}; + } + + &:not(:has(> *))::before { + width: var(--btn-icon-size); + height: var(--btn-icon-size); + content: ''; + background-color: currentColor; + @include mask-icon(var(--number-input-icon), $size: 100%); + } + + &:first-child { + @include border-start-radius(calc(var(--number-input-border-radius) - #{$input-border-width})); + } + + &:last-child { + @include border-end-radius(calc(var(--number-input-border-radius) - #{$input-border-width})); + } + } + + @each $state, $data in $form-validation-states { + &:has(> .form-control.is-#{$state}), + .was-validated &:has(> .form-control:#{$state}) { + border-color: map.get($data, 'border-color'); + + &:focus-within { + outline-color: map.get($data, 'focus-ring-color'); + } + + // The box sits between the field and its feedback text, so the + // `.is-invalid ~ .invalid-feedback` rule has to be repeated from here. + ~ .#{$state}-feedback, + ~ .#{$state}-tooltip { + display: block; + } + } + } +} + +// Sizes move the same tokens `.form-control-sm` / `.btn-sm` and their `-lg` +// twins read, so the field and the buttons keep one height. +@each $size in (sm, lg) { + .number-input-#{$size} { + --number-input-input-width: #{map.get($number-input-input-widths, $size)}; + --number-input-border-radius: var(--btn-input-#{$size}-border-radius); + + > .form-control { + --control-padding-y: var(--btn-input-#{$size}-padding-y); + --control-font-size: var(--btn-input-#{$size}-font-size); + --control-line-height: var(--btn-input-#{$size}-line-height); + } + + > .btn { + --btn-padding-y: var(--btn-input-#{$size}-padding-y); + --btn-font-size: var(--btn-input-#{$size}-font-size); + --btn-line-height: var(--btn-input-#{$size}-line-height); + --btn-icon-size: #{map.get($number-input-icon-sizes, $size)}; + } + } +} diff --git a/core/scss/utils/_colors.scss b/core/scss/utils/_colors.scss index 5fb8d749c..3a5a0055d 100644 --- a/core/scss/utils/_colors.scss +++ b/core/scss/utils/_colors.scss @@ -61,6 +61,17 @@ } } +// Fills +.bg-fill { + background-color: var(--fill) !important; +} + +@each $level in (primary, secondary, tertiary, quaternary) { + .bg-fill-#{$level} { + background-color: var(--fill-#{$level}) !important; + } +} + @each $color, $value in $theme-colors { .text-#{'' + $color} { --text-opacity: 1; diff --git a/core/scss/vendor/_index.scss b/core/scss/vendor/_index.scss index 8dae1a8df..9b500f024 100644 --- a/core/scss/vendor/_index.scss +++ b/core/scss/vendor/_index.scss @@ -3,6 +3,7 @@ @forward 'tom-select'; @forward 'apexcharts'; @forward 'jsvectormap'; +@forward 'maplibre'; @forward 'dropzone'; @forward 'fslightbox'; @forward 'plyr'; diff --git a/core/scss/vendor/_maplibre.scss b/core/scss/vendor/_maplibre.scss new file mode 100644 index 000000000..166508fe6 --- /dev/null +++ b/core/scss/vendor/_maplibre.scss @@ -0,0 +1,290 @@ +@use '../config' as *; + +.maplibregl-map { + font-family: inherit; + font-size: $h5-font-size; + line-height: $line-height-base; +} + +// Tabler buttons over the map, in the top end corner +.map-controls { + position: absolute; + inset-block-start: #{$map-controls-offset}; + inset-inline-end: #{$map-controls-offset}; + z-index: 2; + display: flex; + flex-direction: column; + gap: #{$map-controls-gap}; +} + +// The hover and active colors of a button are see-through, which would show +// the map under it. For a button inside a map, or in an element laid over one, +// keep the button color and draw the hover color as a layer on top. +[data-bs-toggle='map'], +[data-bs-toggle='map'] ~ *, +[data-tblr-toggle='map'], +[data-tblr-toggle='map'] ~ * { + .btn:hover { + background-color: var(--btn-bg); + background-image: linear-gradient(var(--btn-hover-bg), var(--btn-hover-bg)); + } + + .btn:active, + .btn.active { + background-color: var(--btn-bg); + background-image: linear-gradient(var(--btn-active-bg), var(--btn-active-bg)); + } +} + +// A map button with no content gets its icon from its action +[data-bs-map-action]:empty::before { + width: #{$map-control-icon-size}; + height: #{$map-control-icon-size}; + content: ''; + background: currentColor; + mask: var(--map-control-icon) center / contain no-repeat; +} + +@each $action, $shape in $map-control-icons { + [data-bs-map-action='#{$action}'] { + --map-control-icon: #{escape-svg(url("data:image/svg+xml,#{$shape}"))}; + } +} + +// Attribution +.maplibregl-ctrl.maplibregl-ctrl-attrib { + color: var(--secondary); + background-color: color-transparent(var(--bg-surface), 0.7); + + a { + color: inherit; + } +} + +.maplibregl-ctrl-attrib.maplibregl-compact { + color: var(--secondary); + background-color: var(--bg-surface); +} + +.maplibregl-ctrl-attrib-button:focus { + box-shadow: none; + @include focus-ring(); +} + +.maplibregl-ctrl-scale { + color: var(--body-color); + background-color: color-transparent(var(--bg-surface), 0.75); + border-color: var(--body-color); +} + +// Popup: a small bordered panel with no arrow +.maplibregl-popup .maplibregl-popup-content { + padding: 0.75rem; + color: var(--body-color); + background: var(--bg-surface); + box-shadow: + 0 0 0 var(--border-width) var(--border-color), + #{$box-shadow-md}; + @include border-radius(var(--border-radius)); +} + +.maplibregl-popup-tip { + display: none; +} + +// A popup fades in and grows a little as it opens +.maplibregl-popup-content { + animation: map-popup-in 0.2s ease-out; + + @media (prefers-reduced-motion: reduce) { + animation: none; + } +} + +@keyframes map-popup-in { + from { + opacity: 0; + transform: scale(0.95); + } +} + +// Room for the close button, which MapLibre pins to the right edge in both directions +.maplibregl-popup .maplibregl-popup-content:has(.maplibregl-popup-close-button) { + /* rtl:ignore */ + padding-right: 2rem; +} + +.maplibregl-popup-close-button { + width: 1.25rem; + height: 1.25rem; + padding: 0; + margin: 0.25rem; + font-size: 1rem; + line-height: 1; + color: var(--secondary); + @include border-radius(var(--border-radius-sm)); + + &:hover { + color: var(--body-color); + background-color: var(--bg-surface-secondary); + } +} + +// Popup used as a tooltip: a short inverted label, shown on hover +.map-tooltip .maplibregl-popup-content { + padding: 0.25rem 0.5rem; + color: var(--text-inverted); + pointer-events: none; + background: var(--bg-surface-inverted); + box-shadow: #{$box-shadow-md}; +} + +// Popup that holds a card-like block: an image on top, padded content below +.map-popup-card.maplibregl-popup { + .maplibregl-popup-content { + width: #{$map-popup-card-width}; + padding: 0; + overflow: hidden; + } + + .maplibregl-popup-close-button { + color: var(--body-color); + background-color: var(--bg-surface); + } +} + +// Markers written as children of the map element stay hidden until the +// component moves them onto the map +[data-bs-toggle='map'] > [data-bs-lng], +[data-tblr-toggle='map'] > [data-tblr-lng] { + display: none; +} + +// Marker drawn in HTML. MapLibre moves the marker with `transform`, so the +// shape is drawn by the pseudo-element and the root keeps no transform. +.map-marker { + // scss-docs-start map-marker-css-vars + --map-marker-size: #{$map-marker-size}; + --map-marker-bg: #{$map-marker-bg}; + --map-marker-color: #{$map-marker-color}; + --map-marker-border-width: #{$map-marker-border-width}; + --map-marker-border-color: #{$map-marker-border-color}; + --map-marker-font-size: #{$map-marker-font-size}; + --map-marker-icon-size: #{$map-marker-icon-size}; + // scss-docs-end map-marker-css-vars + position: relative; + z-index: 0; + display: inline-flex; + align-items: center; + justify-content: center; + min-width: var(--map-marker-size); + min-height: var(--map-marker-size); + padding: var(--map-marker-border-width); + font-size: var(--map-marker-font-size); + font-weight: var(--font-weight-medium); + line-height: 1; + color: var(--map-marker-color); + cursor: pointer; + + &::before { + position: absolute; + inset: 0; + z-index: -1; + content: ''; + background: var(--map-marker-bg); + filter: #{$map-marker-shadow}; + border: var(--map-marker-border-width) solid var(--map-marker-border-color); + @include border-radius(var(--border-radius-pill)); + } + + // On a map the marker is positioned by MapLibre + &.maplibregl-marker { + position: absolute; + } + + .icon { + --icon-size: var(--map-marker-icon-size); + } +} + +.map-marker-dot { + --map-marker-size: #{$map-marker-dot-size}; +} + +// A ring that grows out of the marker and fades, for a live position +.map-marker-pulse::after { + position: absolute; + inset: 0; + z-index: -2; + content: ''; + background: var(--map-marker-bg); + animation: map-marker-pulse 1.6s ease-out infinite; + @include border-radius(var(--border-radius-pill)); + + @media (prefers-reduced-motion: reduce) { + animation: none; + } +} + +@keyframes map-marker-pulse { + from { + opacity: 0.5; + } + + to { + opacity: 0; + transform: scale(2.6); + } +} + +// Short text under a marker +.map-marker-label { + position: absolute; + top: 100%; + left: 50%; + margin-top: 0.125rem; + font-size: $h6-font-size; + font-weight: var(--font-weight-medium); + line-height: 1.2; + color: var(--body-color); + text-shadow: + 0 0 2px var(--bg-surface), + 0 0 2px var(--bg-surface), + 0 0 4px var(--bg-surface); + white-space: nowrap; + transform: translateX(-50%); +} + +// Teardrop: a square with one sharp corner, turned to point down. Its tip +// reaches 20.7% below the circle, so the root is that much taller. +.map-marker-pin { + box-sizing: content-box; + width: var(--map-marker-size); + min-width: 0; + height: var(--map-marker-size); + min-height: 0; + padding: 0 0 calc(var(--map-marker-size) * 0.207); + + &::before { + bottom: auto; + height: var(--map-marker-size); + @include border-radius(50% 50% 50% 0); + transform: rotate(-45deg); + } +} + +@each $name, $color in $theme-colors { + .map-marker-#{$name} { + --map-marker-bg: var(--#{$name}); + --map-marker-color: var(--#{$name}-fg); + } +} + +// The attribution icon is a black SVG image +@if $enable-dark-mode { + @include color-mode(dark) { + .maplibregl-ctrl-attrib-button { + @include autodark-image(); + } + } +} diff --git a/core/scss/vendor/_tom-select.scss b/core/scss/vendor/_tom-select.scss index b88357da0..07c4477b3 100644 --- a/core/scss/vendor/_tom-select.scss +++ b/core/scss/vendor/_tom-select.scss @@ -79,13 +79,19 @@ // padding, 1px border and 3px bottom margin out of the control's padding so // the chips fill the same height. The font size is `!important` because the // preset sets its own on `.form-select-sm.has-items .ts-control`, which -// outranks this selector. +// outranks this selector. The radius comes from the wrapper too: the preset +// rounds `.ts-control` with `var(--bs-border-radius*)`, which Tabler does not +// define, so the focus ring of a small or large select came out square. It is +// `!important` because the preset's `.ts-wrapper.form-select-sm .ts-control` +// (and `-lg`) has the same specificity, so it would otherwise win whenever the +// preset CSS is loaded after `tabler-vendors.css`. .ts-wrapper.form-control, .ts-wrapper.form-select { .ts-control { padding: var(--control-padding-y) var(--control-padding-x); font-size: var(--control-font-size) !important; line-height: var(--control-line-height); + border-radius: inherit !important; } &.multi.has-items .ts-control { @@ -94,6 +100,13 @@ } } +// In an `.input-icon`, the wrapper carries the `.form-select` class that gets the room for +// the icon, but the preset draws the text in `.ts-control` with its own padding: leave the +// room for a leading icon there. +.input-icon > .input-icon-addon ~ .ts-wrapper .ts-control { + padding-inline-start: var(--input-icon-padding-start); +} + // Every declaration below is `!important`: the bootstrap5 preset CSS targets // these same selectors at equal specificity, and whichever stylesheet is // included last otherwise wins outright (not just for z-index — the preset's @@ -161,12 +174,28 @@ } } -.ts-wrapper.disabled .ts-control { - background-color: var(--secondary-bg); - opacity: 1; +.ts-wrapper.disabled { + .ts-control { + background-color: var(--secondary-bg); + opacity: 1; - & > div.item { - color: var(--gray-500); + & > div.item { + color: var(--gray-500); + } + } + + // The `.form-control` / `.form-select` wrapper draws the rounded border, so a + // background on the `.ts-control` inside it spilled over the corners. Paint the + // disabled state on the wrapper with the tokens of a native `:disabled` control, + // which also covers a single select with a value (`.full`, painted `--body-bg`). + &.form-control, + &.form-select { + background-color: var(--control-disabled-bg); + border-color: var(--control-disabled-border-color); + + .ts-control { + background-color: transparent; + } } } diff --git a/core/tsconfig.types.json b/core/tsconfig.types.json index b5e5e1e66..6dda2fec3 100644 --- a/core/tsconfig.types.json +++ b/core/tsconfig.types.json @@ -8,5 +8,5 @@ "rootDir": "js", "outDir": "dist/types" }, - "include": ["js/tabler.ts", "js/tabler-theme.ts", "js/src/**/*"] + "include": ["js/tabler.ts", "js/tabler-theme.ts", "js/tabler-map.ts", "js/tabler-vector-map.ts", "js/maps/*.ts", "js/src/**/*"] } diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 1886c5b52..a640b7961 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,5 +1,44 @@ # @tabler/docs +## 1.7.0 + +### Minor Changes + +- ace9a4f: Added `.datagrid-horizontal` and `.datagrid-cols-{n}` classes and more datagrid examples and docs. +- 6cd45d0: Added the Icon design guide page to the Tabler Icons docs, with the SVG format, naming rules and icon requests. +- 8f00eab: Added the Forms → CSS variables page: the control scale, state colors and every `--tblr-control-*`, add-on, floating, range, check and switch token. +- f7c848a: Added the Hover gallery component with `.hover-gallery`, `.hover-gallery-item` and `.hover-gallery-indicators` classes. +- c493436: Added the `.list-icon` component for lists with icon markers, with `.list-icon-inline` for entries in a row. +- 681b6eb: Added the image label component: `.img-labeled` and `.img-label` pin a short text or an icon to a corner of an image. +- 03e2775: Updated `` to a compact key cap and added `.kbd-group` and `--tblr-kbd-*` variables for keyboard shortcuts. +- a845ab0: Added the maps page demos on MapLibre instead of Mapbox GL and Google Maps, and the Map blocks page with seven blocks such as a store locator. +- a845ab0: Added the `MapView` component in `tabler-map.js` on MapLibre GL JS 6: `data-bs-toggle="map"` makes a themed map with markers, popups, routes and a globe. +- d6cfffb: Added the `.mockup-browser` component that frames a screenshot or any content in a browser window with an address bar. +- 6c46d55: Added `NavOverflow` component that moves nav items that do not fit into a More dropdown, with a `.nav-overflow` wrapper. +- 3f3aae9: Added the `NumberInput` component, a `.number-input` quantity input with buttons, bounds, hold-to-repeat and keyboard support. +- 744f9fe: Added the `.text-shimmer` class that sweeps a highlight across a label, with an optional `.text-shimmer-loader` spinner. +- 8ab6441: Added the `VectorMap` plugin: an SVG world map with values, color scales, a legend, markers, lines, selection, tooltips and zoom. + +### Patch Changes + +- 964eb7c: Added `dateFormat` patterns such as `iso` or `DD.MM.YYYY` and a `boundInput` option for ISO form values to `Datepicker`. +- 725e45d: Added the `weekNumbers` option to `Datepicker` to show the week number in front of each row. +- d07533c: Updated the 1.7 upgrade guide with the visual changes, the Shape menu entry, `combined` navbar position, flag aliases and `--tblr-kbd-*` variables. +- 0647af1: Added the `--tblr-fill-*` tokens and `.bg-fill-*` utilities, used by badges, avatars, code, progress and range tracks and the hover background. +- 8f00eab: Added `--tblr-form-check-*` and `--tblr-form-switch-*` variables, with shared `--tblr-control-checked-bg` and disabled tokens on `:root`. +- 8f00eab: Added `--tblr-control-*` variables to `.form-control` and `.form-select`; sizes and the file button now follow them. +- bb60211: Updated `.form-switch` with a filled gray off track, a white knob with an even gap and a new `$form-switch-bg` variable. +- ba4e87a: Added `.icon-inline` to icons that sit in a line of text in the demo cards, preview pages and docs examples. +- 68da446: Updated the flags plugin to Tabler Flags: 304 flags at a 5:4 ratio, 50 new like `.flag-country-nato`, old codes kept as aliases. +- 8f00eab: Added `--tblr-input-group-addon-*`, `--tblr-form-floating-*` and `--tblr-range-*` variables to input groups, floating labels and range inputs. +- 9d837ea: Fixed `.input-icon` icons off-center in small and large inputs and selects: the addon follows the control size. +- 69c1114: Added item slots, `.list-group-sm` and `.list-group-lg` sizes, selectable and nested items to the list group. +- 5cbc443: Added a `mist` gray palette for `data-bs-theme-base` and removed the hidden `pink` base from `tabler-themes.css`. +- 00746f2: Added `.input-icon` support for Tom Select: a leading icon no longer overlaps the selected value. +- ed41f04: Updated the promo top banner to load only on production deploys, not on branch previews. +- b1aac09: Updated Tabler Illustrations to v1.18.0 with 11 new illustrations. +- 8dc1ffe: Updated Tabler Icons to v3.49.0. + ## 1.6.1 ### Patch Changes diff --git a/docs/components/Example.astro b/docs/components/Example.astro index bef3dd00b..cfd962051 100644 --- a/docs/components/Example.astro +++ b/docs/components/Example.astro @@ -27,9 +27,11 @@ interface Props { columnFullWidth?: boolean hideCode?: boolean codeOnly?: boolean + /** no padding around the demo, for content that fills the whole area, such as a map */ + flush?: boolean } -const { html: htmlProp, code, raw, overflow = 'auto', background = 'surface-secondary', class: className, height, column, centered, vertical, columnFullWidth, hideCode, codeOnly } = Astro.props +const { html: htmlProp, code, raw, overflow = 'auto', background = 'surface-secondary', class: className, height, column, centered, vertical, columnFullWidth, hideCode, codeOnly, flush } = Astro.props const backgroundClasses: Record = { 'surface-secondary': 'bg-surface-secondary', @@ -52,7 +54,7 @@ const hasDemo = !codeOnly const hasCode = !hideCode const exampleClasses = ['example fs-base border', hasCode ? 'rounded-top' : 'rounded', !raw && 'd-flex flex-wrap justify-content-center', `overflow-${overflow}`, 'position-relative', backgroundClasses[background], className] -const innerClasses = ['p-6 w-full', column && 'd-flex gap-3 flex-column', !column && centered && `d-flex flex-fill flex-wrap gap-2 justify-content-center${vertical ? ' align-items-center flex-column' : ' justify-content-center'}`, columnFullWidth && 'd-flex flex-fill flex-column gap-2'] +const innerClasses = [flush ? 'p-0' : 'p-6', 'w-full', column && 'd-flex gap-3 flex-column', !column && centered && `d-flex flex-fill flex-wrap gap-2 justify-content-center${vertical ? ' align-items-center flex-column' : ' justify-content-center'}`, columnFullWidth && 'd-flex flex-fill flex-column gap-2'] const highlighted = hideCode ? '' : await highlightCode(beautifyHtml(code ?? html), 'html') --- diff --git a/docs/components/Flags.astro b/docs/components/Flags.astro index 8c2f20c04..5d8da808e 100644 --- a/docs/components/Flags.astro +++ b/docs/components/Flags.astro @@ -3,39 +3,52 @@ import MarkdownSource from './MarkdownSource.astro' import { markdownTable } from '@shared/lib/markdown-source' import flags from '@data/flags.json' -// Same list as a table for the page's .md mirror -const markdown = markdownTable( - ['Country', 'Code', 'CSS class'], - flags.map((flag) => [flag.name, `\`${flag.flag}\``, `\`.flag-country-${flag.flag}\``]), -) +const categories = ['Countries', 'Territories', 'Unions', 'Other'] +const groups = categories.map((category) => ({ category, flags: flags.filter((flag) => flag.category === category) })) + +// Same list as tables for the page's .md mirror +const markdown = groups + .map(({ category, flags }) => { + const table = markdownTable( + ['Name', 'Code', 'CSS class'], + flags.map((flag) => [flag.name, `\`${flag.flag}\``, `\`.flag-country-${flag.flag}\``]), + ) + return `### ${category}\n\n${table}` + }) + .join('\n\n') --- - - - - - - - - - - - { - flags.map((flag) => ( - - - - - - - )) - } - -
FlagNameCountry codeCSS class
-
-
{flag.name} - {flag.flag} - - .flag-country-{flag.flag} -
+{ + groups.map(({ category, flags }) => ( + +

{category}

+ + + + + + + + + + + {flags.map((flag) => ( + + + + + + + ))} + +
FlagNameCodeCSS class
+
+
{flag.name} + {flag.flag} + + .flag-country-{flag.flag} +
+
+ )) +} diff --git a/docs/content/illustrations/customization.mdx b/docs/content/illustrations/customization.mdx index e950e965f..975be0643 100644 --- a/docs/content/illustrations/customization.mdx +++ b/docs/content/illustrations/customization.mdx @@ -266,7 +266,7 @@ When `--tblr-illustrations-primary` is not set, the illustrations fall back to ` - + +
`} + code={` +${cdnJsTag()} +${cdnMapJsTag()}`} /> -Then create a free account on [mapbox.com](https://www.mapbox.com/) and copy your public access token. It starts with `pk.`. Set it once, before you create a map: +The add-on loads MapLibre GL JS by itself, from `dist/libs/maplibre-gl` next to the Tabler scripts. Tabler uses version 6, which is published as an ES module. When your files are somewhere else, point the `library` option at the module: `data-bs-library="/vendor/maplibre-gl.mjs"`. The library loads two more files from its own folder, `maplibre-gl-shared.mjs` and `maplibre-gl-worker.mjs`, so keep the three together. -```js -mapboxgl.accessToken = 'pk.your-token-here' +If the page sets `window.maplibregl` before the add-on runs, the component uses that and loads nothing. This is the way to go with a bundler: + +```bash +npm install maplibre-gl ``` - - A secret token (`sk.`) must never be sent to the browser. Limit the token to your own domains in the Mapbox dashboard. +```js +import * as maplibregl from 'maplibre-gl' +import 'maplibre-gl/dist/maplibre-gl.css' + +window.maplibregl = maplibregl + +const { MapView } = await import('@tabler/core/dist/js/tabler-map.esm.js') +``` + + + Older versions, including all of version 5, have a known security issue in the code that cleans the attribution text. +Browsers do not load ES modules from a page opened straight from the disk. Use a local server to see the maps while you work. + +Tabler styles the map controls, popups and attribution to match the interface, in light and dark mode. These styles are in `tabler-vendors.css`, so load it after the MapLibre stylesheet: + + + ## Usage -### Markup +### Basic map -Put the map container inside a `ratio` element, so the map keeps its shape on every screen size. Give the inner element an `id` and the `w-100 h-100` classes. +Put the map element inside a `ratio` element, so the map keeps its shape on every screen size. `data-bs-center` takes the longitude first and the latitude second. This is the opposite of what most map services show. - -
-
-
+ + Change the shape with the ratio classes, for example `ratio-21x9` for a wide map or `ratio-1x1` for a square one. -### Init the map +### Map styles -Create the map after the DOM is ready. Pass the id of your container, a style, a zoom level, and a center point. +The map uses a light gray style in light mode and a dark one in dark mode. Tabler softens their colors, so markers and data stand out. Set another style with `data-bs-map-style`: the name of an OpenFreeMap style, or the URL of any MapLibre style. + +| Style | Result | +| --- | --- | +| `positron` | Light gray map. The default in light mode. | +| `dark` | Dark gray map. The default in dark mode. | +| `liberty` | Street map in color. | +| `bright` | Street map with stronger colors. | +| `fiord` | Dark blue map. | + + + + + +`data-bs-map-style-dark` sets the style for dark mode. Styles from other providers work the same way, as long as they follow the MapLibre style format. Check the terms of each provider, as many of them need an API key or a paid plan. + +### Dark mode + +The map follows the nearest `data-bs-theme` and changes its style when the theme changes. Markers and popups stay on the map. To keep one look in both modes, set `data-bs-map-theme` to `light` or `dark`. + +### Markers + +Each child element with `data-bs-lng` and `data-bs-lat` becomes a marker. Use the `.map-marker` class to draw it: a circle in the primary color that can hold an icon, a short count or an avatar. Put a `.map-marker-label` element inside the marker for a short text under it. + +Add `.map-marker-dot` for a small dot, or `.map-marker-pin` for a pin that points down. A pin needs `data-bs-anchor="bottom"`, so its tip sits on the place. Change the color with `.map-marker-{color}`. + + + + + + Store + 12 + + + + + + +Add `data-bs-draggable="true"` to let people move a marker, for example to pick an address. Add `.map-marker-pulse` for a ring that grows out of the marker, for a live position or a place that needs attention. + +HTML markers work well for up to a few hundred places. For more, use [clusters](#clusters). + +### Popups + +Add `data-bs-popup` to a marker for a short text that opens when the marker is clicked. Tabler draws the popup as a small bordered panel with no arrow, and fades it in. + + + + + +For richer content, put a `