' })
+
+ 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,"))};
+ }
+}
+
+// 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')
---
-
-
-
-
Flag
-
Name
-
Country code
-
CSS class
-
-
-
- {
- flags.map((flag) => (
-
-
-
-
-
{flag.name}
-
- {flag.flag}
-
-
- .flag-country-{flag.flag}
-
-
- ))
- }
-
-
+{
+ groups.map(({ category, flags }) => (
+
+
{category}
+
+
+
+
Flag
+
Name
+
Code
+
CSS class
+
+
+
+ {flags.map((flag) => (
+
+
+
+
+
{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}`.
+
+
+
+
+
+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 `` inside the marker. Its content becomes the popup. Add the `map-popup-card` class to the template for a popup with an image: it removes the padding and sets a fixed width, so the image reaches the edges. `data-bs-close-button="true"` on the template adds a close button, and `data-bs-popup-open="true"` on the marker opens the popup from the start.
+
+
+
+
+
+### Controls
+
+Add `data-bs-controls="true"` for zoom buttons in the top corner of the map. They are regular Tabler buttons, so they follow the theme. For more buttons, pass a list of names:
+
+| Name | Buttons |
+| --- | --- |
+| `zoom` | Zoom in and zoom out. |
+| `north` | Turns the map back to north up. |
+| `locate` | Moves the map to the position of the device. The browser asks for permission first. |
+| `fullscreen` | Shows the map on the whole screen, and back. |
+
+```html
+
+```
+
+The buttons have English names for screen readers. Change them with the `labels` option, by action:
+
+```html
+
+```
+
+You can also write the buttons yourself. Any button with `data-bs-map-action` works on the map it is inside, or on the map its `data-bs-target` points at. The actions are `zoom-in`, `zoom-out`, `north`, `locate` and `fullscreen`. A button with no content gets a matching icon; put the buttons in a `.map-controls` element to place them in the top corner.
+
+```html
+
+
+
+
+
+
+
+
+```
+
+### Route
+
+Draw a path with `data-bs-route`: a list of `[longitude, latitude]` points. The line takes the primary color and is drawn again after a theme change. Mark its ends with markers.
+
+```html
+
+
+
+
+```
+
+To show how far along a path something is, add `data-bs-route-progress` with the index of the point reached so far, counted from 0. The path up to that point stays solid and the rest is dashed.
+
+```html
+
+```
+
+### Areas and other shapes
+
+`data-bs-geojson` draws [GeoJSON](https://geojson.org/) over the map: areas with a light fill and an outline, lines, and points as small circles. Pass the GeoJSON itself, or the URL of a file.
+
+```html
+
+```
+
+Both use the primary color. Set `data-bs-layer-color` to another color, for example `--tblr-red` or `#d63939`.
+
+### Globe
+
+Add `data-bs-globe="true"` to draw the map as a 3D globe. Use a low zoom, between 1 and 2, to see the whole globe. People can turn it by dragging.
+
+```html
+
+```
+
+### Content over the map
+
+To put buttons or a small card over the map, place them next to the map element and position them with the utility classes:
+
+```html
+
+
+
+
+
+
+
+
+
+```
+
+### Attribution
+
+MapLibre adds the credit for the map data in the corner of the map. OpenFreeMap requires it, so do not hide it.
+
+## Layers
+
+The route and GeoJSON options cover simple shapes. For anything more, such as clusters, dashed lines or circles sized by a value, take the MapLibre map from the component and use its own API. The `map` property is the [MapLibre map](https://maplibre.org/maplibre-gl-js/docs/API/classes/Map/).
+
+A layer reads its data from a GeoJSON source. Layers are part of the map style, so add them in `onStyle()`. It runs when the style has loaded, and again each time the style changes, for example on a theme change.
```js
document.addEventListener('DOMContentLoaded', function () {
- mapboxgl.accessToken = 'pk.your-token-here'
+ const view = tabler.MapView.getInstance('#map-stores')
- const map = new mapboxgl.Map({
- container: 'map-simple',
- style: 'mapbox://styles/mapbox/streets-v11',
- zoom: 13,
- center: [13.4049, 52.518827],
+ view.onStyle(function (map) {
+ map.addSource('stores', { type: 'geojson', data: stores })
+ map.addLayer({
+ id: 'stores',
+ type: 'circle',
+ source: 'stores',
+ paint: { 'circle-color': tabler.MapView.color('--tblr-primary'), 'circle-radius': 6 },
+ })
+ })
+
+ // Runs once the map exists: the library may load a moment after the page
+ view.ready(function (map) {
+ map.on('click', 'stores', function (event) {
+ console.log(event.features[0].properties)
+ })
})
})
```
-`center` takes the longitude first and the latitude second. This is the Mapbox order, and it is the opposite of what most map services show.
+For an area, use a `Polygon` geometry with a `fill` layer, and a `line` layer for its outline.
-### Map styles
+### Theme colors in layers
-Set `style` to any Mapbox style URL. These three are used in the Tabler demos:
-
-| Style | Result |
-| --- | --- |
-| `mapbox://styles/mapbox/streets-v11` | Default street map. |
-| `mapbox://styles/mapbox/light-v10` | Light map, good under charts and markers. |
-| `mapbox://styles/mapbox/satellite-v9` | Satellite photos. |
-
-### Markers
-
-Add a marker for each place. `setLngLat` takes the same longitude and latitude order as `center`.
+MapLibre draws layers on a canvas, so a CSS variable does not work in `paint`, and neither do the `oklch()` values Tabler colors are written in. `MapView.color()` turns a variable, or any CSS color, into a plain `rgb()` color. Call it inside `onStyle()`, so the colors are read again after a theme change.
```js
-const cities = [
- [-58.666667, -34.58333333], // Buenos Aires
- [16.366667, 48.2], // Vienna
- [116.383333, 39.91666667], // Beijing
-]
+tabler.MapView.color('--tblr-primary') // 'rgb(6, 111, 209)'
+```
-cities.forEach(function (coords) {
- new mapboxgl.Marker({ color: 'var(--tblr-primary)' }).setLngLat(coords).addTo(map)
+### Clusters
+
+Put many points in a GeoJSON source with `cluster: true`. MapLibre then groups nearby points and splits the groups as you zoom in.
+
+```js
+map.addSource('stores', { type: 'geojson', data: stores, cluster: true, clusterRadius: 40 })
+
+// Circle for each cluster, larger for more points
+map.addLayer({
+ id: 'clusters',
+ type: 'circle',
+ source: 'stores',
+ filter: ['has', 'point_count'],
+ paint: { 'circle-color': tabler.MapView.color('--tblr-primary'), 'circle-radius': ['step', ['get', 'point_count'], 14, 10, 18, 25, 24] },
+})
+
+// Number of points in the cluster
+map.addLayer({
+ id: 'cluster-count',
+ type: 'symbol',
+ source: 'stores',
+ filter: ['has', 'point_count'],
+ layout: { 'text-field': ['get', 'point_count_abbreviated'], 'text-font': ['Noto Sans Regular'], 'text-size': 12 },
+ paint: { 'text-color': '#ffffff' },
})
```
-The `color` option is written to the `fill` attribute of the marker icon. A CSS variable works there, so `var(--tblr-primary)` keeps the markers in your theme color, including after a theme change.
+`text-font` has to be a font the map style provides. All OpenFreeMap styles have `Noto Sans Regular`.
-## Examples
+### Tooltips
-### Map in a card
+For a short label on hover, add the `map-tooltip` class to a MapLibre popup. It turns the popup into a small dark label.
-Add a card title above the map. This is the usual layout for a small map next to other content.
+```js
+const tooltip = new maplibregl.Popup({ closeButton: false, offset: 12, className: 'map-tooltip' })
-
-
-
-
Simple map
-
-
-
-
-
-
+map.on('mousemove', 'cities', function (event) {
+ tooltip.setLngLat(event.lngLat).setText(event.features[0].properties.name).addTo(map)
+})
+map.on('mouseleave', 'cities', function () {
+ tooltip.remove()
+})
+```
-### Full-card map
+Live versions of a route, an area, clusters, a bubble map, a globe with arcs and a city switcher are on the [maps preview page](https://preview.tabler.io/maps.html). The [map blocks page](https://preview.tabler.io/map-blocks.html) has larger examples: a store locator, a list of homes with prices on the map, a delivery tracker, an address form with a draggable pin, a server status map, a map of live users and a vector map of users by country.
-Drop the card body to let the map fill the whole card. Add `rounded` to the container, so the map follows the card corners.
+## JavaScript
-
-
-
-
-
-
-
+### Via data attributes
-Live versions of these maps are on the [maps preview page](https://preview.tabler.io/maps.html).
+An element with `data-bs-toggle="map"` becomes a map when the `tabler-map.js` script loads:
+
+
+
+### Via JavaScript
+
+Create a map from your own code with the same options:
+
+```js
+const view = new tabler.MapView('#map', {
+ center: [13.405, 52.52],
+ zoom: 12,
+ controls: true,
+})
+```
+
+### Options
+
+Pass options as data attributes, with the name written in kebab case after `data-bs-`, or in the config object.
+
+| Name | Type | Default | Description |
+| --- | --- | --- | --- |
+| `library` | string | MapLibre in `dist/libs` | URL of the MapLibre GL JS module, loaded when `window.maplibregl` is not set. A relative URL starts at the Tabler script. `null` turns the loading off. |
+| `center` | array, string | `[0, 0]` | Center of the map: `[longitude, latitude]`, or the two numbers in a string such as `"13.405,52.52"`. |
+| `zoom` | number | `1` | Zoom level, from 0 for the whole world to about 20 for a building. |
+| `mapStyle` | string | `'positron'` | Name of an OpenFreeMap style, or the URL of a MapLibre style. |
+| `mapStyleDark` | string | `'dark'` | Style used in dark mode. Set `null` to keep `mapStyle` in both modes. |
+| `mapTheme` | string | `'auto'` | `'auto'` follows the nearest `[data-bs-theme]`; `'light'` or `'dark'` keeps one look. |
+| `palette` | boolean | `true` | Softer colors for the `positron` and `dark` styles. Other styles are left as they are. |
+| `controls` | boolean, string | `false` | Buttons over the map: `true` for zoom, or a list of `zoom`, `north`, `locate` and `fullscreen`. |
+| `labels` | object | `{}` | Names of the buttons for screen readers, by action: `zoom-in`, `zoom-out`, `north`, `locate`, `fullscreen`. |
+| `route` | array | `null` | A path drawn as a line: a list of `[longitude, latitude]` points. |
+| `routeProgress` | number | `null` | Index of the route point reached so far. The path up to it is solid, the rest is dashed. |
+| `geojson` | object, string | `null` | GeoJSON drawn over the map: an object, or the URL of a file. |
+| `layerColor` | string | `'--tblr-primary'` | Color of the route and the GeoJSON shapes: a CSS color or a custom property. |
+| `globe` | boolean | `false` | Draw the map as a 3D globe. |
+| `mapOptions` | object | `{}` | Any [MapLibre map option](https://maplibre.org/maplibre-gl-js/docs/API/type-aliases/MapOptions/), passed straight through. |
+
+A marker element takes these attributes:
+
+| Attribute | Description |
+| --- | --- |
+| `data-bs-lng`, `data-bs-lat` | Position of the marker. Both are needed. |
+| `data-bs-anchor` | Part of the marker that sits on the position: `center` by default, `bottom` for a pin. |
+| `data-bs-draggable` | Set `true` to let people move the marker. |
+| `data-bs-popup` | Text of a popup that opens when the marker is clicked. |
+| `data-bs-popup-open` | Set `true` to open the popup from the start. |
+
+### Methods
+
+| Name | Description |
+| --- | --- |
+| `map` | The MapLibre map, for layers, events and everything else the options do not cover. `null` until the library has loaded. |
+| `ready(callback)` | Runs the callback with the MapLibre map once it exists. |
+| `onStyle(callback)` | Runs the callback with the map each time a style loads. The place to add layers. |
+| `markers` | The MapLibre markers made from the child elements, in document order. |
+| `zoomIn()`, `zoomOut()` | Zooms the map by one level. |
+| `resetNorth()` | Turns the map back to north up, with no tilt. |
+| `locate()` | Moves the map to the position of the device. |
+| `toggleFullscreen()` | Shows the map on the whole screen, and back. |
+| `dispose()` | Removes the map, its markers and the instance. |
+| `MapView.color(value)` | Turns a CSS variable or color into a plain `rgb()` color for a map layer. |
+| `MapView.getInstance(element)` | Returns the instance for an element, or `null`. |
+| `MapView.getOrCreateInstance(element)` | Returns the instance, or creates one. |
+
+### Events
+
+| Event | Description |
+| --- | --- |
+| `load.bs.map` | Fires on the map element once the map has loaded its style and first tiles. |
+
+## Using Mapbox GL JS
+
+MapLibre GL JS started as a fork of Mapbox GL JS, so the two share most of their API. The map component works with MapLibre only. To use Mapbox, write the map script yourself: load its script and stylesheet, set `mapboxgl.accessToken` to your public token, and use a `mapbox://` style URL.
## Accessibility
- The map is a picture for most users. Give the same information as text near it, for example the address or a list of places.
-- Add an `aria-label` to the map container, so screen reader users know what the map shows.
+- Add `role="region"` and an `aria-label` to the map element, so screen reader users know what the map shows.
- Do not put an action only on a marker. Repeat it in a normal link or button below the map.
- Keep a visible fallback when the script fails to load, for example the address in text.
diff --git a/docs/content/ui/components/mockup-browser.mdx b/docs/content/ui/components/mockup-browser.mdx
new file mode 100644
index 000000000..aa5462a26
--- /dev/null
+++ b/docs/content/ui/components/mockup-browser.mdx
@@ -0,0 +1,153 @@
+---
+title: Mockup browser
+summary: A mockup browser is a frame that looks like a browser window, with the window dots and an address bar, to show a page preview or a screenshot.
+description: Show a screenshot or any content inside a browser window frame with the mockup browser component. Add an address, a lock icon or your own colors.
+added-in: '1.7.0'
+related: [/ui/components/image-label, /ui/components/card]
+source: core/scss/ui/_mockup-browser.scss
+css-vars: mockup-browser
+classnames:
+ component:
+ - class: mockup-browser
+ desc: The frame, with a border, rounded corners and a shadow
+ size:
+ - class: mockup-browser-sm
+ desc: A 24px toolbar with smaller dots and text, for thumbnails and cards
+ part:
+ - class: mockup-browser-toolbar
+ desc: The top bar with the window dots and the address bar
+ - class: mockup-browser-controls
+ desc: The three window dots
+ - class: mockup-browser-url
+ desc: The address, centered in the toolbar
+ - class: mockup-browser-body
+ desc: The page area that holds the screenshot or the content
+---
+import Example from '@components/Example.astro';
+import Icon from '@ui/Icon.astro';
+
+## Default markup
+
+A `.mockup-browser` has a `.mockup-browser-toolbar` with the window dots and an address bar, and a `.mockup-browser-body` with the page. An image in the body fills the width of the frame.
+
+
+
+
+
+
preview.tabler.io
+
+
+
+
+
+
+
+
+## Secure address
+
+Put an icon before the address text to show a secure page. The icon is scaled down to match the text.
+
+
+
+
+
+
https://preview.tabler.io
+
+
+
+
+
+
+
+
+## Without an address bar
+
+Leave out `.mockup-browser-url` for a plain window frame with only the dots.
+
+
+
+
+
+
+
+
+
+
+
+
+
+## Sizes
+
+Add `.mockup-browser-sm` for a 24px toolbar with smaller dots and text. Use it for thumbnails and for frames inside cards.
+
+
+
+
+
+
+
+
preview.tabler.io
+
+
+
+
+
+
+
+
+
+
+
+
preview.tabler.io
+
+
+
+
+
+
+
+
+
+
+## Any content
+
+The body takes any markup, not only an image. Add a padding utility to the body when the content needs space from the frame.
+
+
+
+
+
+
example.com/signup
+
+
+
Create an account
+
+
+
+
+
+
+
+
+
+## Custom look
+
+Set the `--tblr-mockup-browser-*` variables on the frame to change the colors, the shadow or the size of the dots. The three dots have their own color variables, `--tblr-mockup-browser-control-color-1` to `-3`.
+
+
+
+
+
+
preview.tabler.io
+
+
+
+
+
+
+
+## Accessibility
+
+- The window dots are decoration. They are an empty element, so screen readers skip them.
+- The address bar is plain text and is read as such. It is not a link and not a form field.
+- Give a screenshot in the body an `alt` text that says what page it shows.
diff --git a/docs/content/ui/components/nav-overflow.mdx b/docs/content/ui/components/nav-overflow.mdx
new file mode 100644
index 000000000..9894e548d
--- /dev/null
+++ b/docs/content/ui/components/nav-overflow.mdx
@@ -0,0 +1,244 @@
+---
+title: Nav overflow
+summary: Nav overflow keeps a nav on one line. Items that do not fit move into a More dropdown, and come back when there is room again.
+description: Keep a nav on one line by moving the items that do not fit into a More dropdown.
+added-in: '1.7.0'
+related: [/ui/layout/navs-tabs, /ui/components/dropdown]
+source: core/scss/ui/_nav-overflow.scss
+classnames:
+ component:
+ - class: nav-overflow
+ desc: Wrapper around the nav. The component measures this element to decide what fits
+ items:
+ - class: nav-overflow-keep
+ desc: Put on a nav item that must never move to the menu
+ toggle:
+ - class: nav-overflow-item
+ desc: The nav item that holds the More toggle and its menu
+ - class: nav-overflow-toggle
+ desc: The More button. Write your own to change its markup or label
+ - class: nav-overflow-menu
+ desc: The dropdown menu that receives the moved items
+ - class: nav-overflow-icon
+ desc: Wrapper around the toggle icon
+ - class: nav-overflow-text
+ desc: Wrapper around the toggle text
+---
+import Example from '@components/Example.astro';
+import CodeDocs from '@components/CodeDocs.astro';
+
+## Overview
+
+A nav with many links wraps to a second line, or runs out of its container, when the screen gets narrow. Nav overflow keeps the nav on one line. Items that do not fit move into a More dropdown at the end of the nav, and come back when there is room again. This is also known as the Priority+ pattern.
+
+Wrap the `.nav` in a `.nav-overflow` element and add `data-bs-toggle="nav-overflow"` to the wrapper. Resize the window to see the items move.
+
+
+
+
+
+How it works:
+
+- The component watches the size of the wrapper, not of the window. It works in a card, a sidebar or any container that changes width.
+- It measures the wrapper, not the nav. Moving items changes the width of the nav, so measuring the nav would feed the result back in.
+- Moved items are copied into the menu as `.dropdown-item`, and the originals are hidden. The `.active` and `.disabled` states are copied too.
+- A link with `data-bs-toggle`, like a tab, works from the menu. The copy sends the click to the original link, and the `.active` state follows.
+- Until the script runs, the nav is clipped to one line but stays visible, so it still works without JavaScript.
+- It works with every nav style: plain, `.nav-pills`, `.nav-tabs` and `.nav-underline`.
+- It also works with the `.navbar-nav` of a navbar. A vertical nav, like the collapsed navbar on a phone, keeps every item visible.
+
+## Tabs
+
+
+
+
+
+## Keep items visible
+
+Add `.nav-overflow-keep` to an item that must never move to the menu, such as the home link or a call to action. The other items move first.
+
+
+
+
+
+## With dropdowns
+
+A nav item that has its own dropdown becomes a nested dropdown in the More menu. It opens towards the start (`.dropstart`) when the menu is aligned to the end, which is the default, and towards the end (`.dropend`) otherwise. The component moves the original `.dropdown-menu` instead of copying it, so its ids and content stay the same, and moves it back when the item fits again.
+
+
+
+
+
+## Customizing the toggle
+
+### Text and icon
+
+Change the text with `data-bs-more-text`. Set it to `false` for an icon-only toggle. The button then gets `aria-label="More"`, so it still has a name.
+
+
+
+
+
+To use another icon, put an element with `data-bs-overflow-icon` inside the wrapper, next to the nav. The component uses it as the toggle icon and removes the original. The icon markup is sanitized first.
+
+```html
+
+
…
+
+
+```
+
+### Your own toggle
+
+To change the markup or the label of the button, write the toggle yourself. The component uses a `.nav-overflow-toggle` it finds in the wrapper instead of creating one:
+
+```html
+
+```
+
+## Collapse all
+
+Use `data-bs-collapse-below` to move every item into the menu when the wrapper is narrower than a breakpoint. Pass a breakpoint name, read from `--tblr-breakpoint-{name}`, or a number of pixels. Above that width the nav collapses item by item again. Items with `.nav-overflow-keep` stay visible.
+
+```html
+
…
+```
+
+## Options
+
+Pass options as data attributes on the wrapper, or in the config object.
+
+| Option | Default | Description |
+| --- | --- | --- |
+| `collapseBelow` | `0` | Wrapper width below which every item moves to the menu. A breakpoint name (`md`) or a number of pixels. `0` turns it off. |
+| `iconPlacement` | `'start'` | Puts the icon before (`start`) or after (`end`) the text. |
+| `menuPlacement` | `'bottom-end'` | Where the menu opens: `bottom-end`, `bottom-start`, `top-end` or `top-start`. |
+| `menuStrategy` | `'absolute'` | Positioning strategy of the menu. Use `fixed` when a parent with `overflow` clips it. |
+| `moreText` | `'More'` | Text of the toggle, inserted as plain text. `false` or an empty string gives an icon-only toggle. |
+| `moreIcon` | three dots | SVG or HTML of the toggle icon. It is sanitized before insertion. |
+| `threshold` | `0` | Minimum number of items that stay visible. |
+
+## JavaScript
+
+On page load Tabler creates a `NavOverflow` for every element with `data-bs-toggle="nav-overflow"`. This is the code that runs:
+
+
+
+Create it yourself for a nav added later, and pass the wrapper, not the nav:
+
+```js
+const navOverflow = new tabler.NavOverflow(document.querySelector('.nav-overflow'), {
+ moreText: 'See all',
+ threshold: 2,
+});
+```
+
+| Method | Description |
+| --- | --- |
+| `update()` | Works out again which items fit. It runs by itself when the wrapper changes size. |
+| `dispose()` | Puts every item back, removes the generated toggle and stops watching the wrapper. |
+| `getInstance(element)` | Static. Returns the component for the wrapper, or `null`. |
+| `getOrCreateInstance(element, config)` | Static. Returns the component for the wrapper and creates it when needed. |
+
+Both events fire on the wrapper:
+
+| Event | Description |
+| --- | --- |
+| `update.bs.navoverflow` | Fires after `update()`. |
+| `overflow.bs.navoverflow` | Fires when items move to the menu. The event carries `overflowCount` and `visibleCount`. |
+
+```js
+document.querySelector('.nav-overflow').addEventListener('overflow.bs.navoverflow', (event) => {
+ console.log(`${event.overflowCount} items in the menu, ${event.visibleCount} visible`);
+});
+```
+
+## Limitations
+
+- An event listener added to a plain nav link is not copied to the menu. Add the listener to the nav or to the wrapper and check `event.target`, so it works for both.
+- A plain link in the menu keeps its `href`, but not its `id`.
+
+## Accessibility
+
+- The More toggle is a `
+The icon follows the size of the input, set with `.form-control-sm` or `.form-control-lg`.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
## Separated inputs
Include an additional element in your input section, such as a [button](/ui/components/button) which can be used to submit the information entered in the input control.
@@ -334,7 +357,7 @@ Add a [range slider](/ui/plugins/range-slider) to make it possible for users to
The track and the thumb read `--tblr-range-*` custom properties on `.form-range`: `--tblr-range-track-height`, `--tblr-range-track-bg`, `--tblr-range-thumb-width`, `--tblr-range-thumb-bg`, `--tblr-range-thumb-border` and the rest of the `track-*` and `thumb-*` set.
-
+
## Input group
@@ -425,7 +448,7 @@ Include a `` in your input control to add a shortcut hint to the control.
- ctrl + K
+ ⌘K
diff --git a/docs/content/ui/forms/number-input.mdx b/docs/content/ui/forms/number-input.mdx
new file mode 100644
index 000000000..02f9b0c95
--- /dev/null
+++ b/docs/content/ui/forms/number-input.mdx
@@ -0,0 +1,180 @@
+---
+title: Number input
+seoTitle: Number input (quantity stepper)
+order: 14
+summary: A number field with a decrease and an increase button, for a quantity in a cart, a number of seats or any value changed in fixed steps.
+description: Add a number input with min, max and step, hold-to-repeat buttons, arrow key support and change events.
+added-in: '1.7.0'
+related: [/ui/forms/elements, /ui/forms/validation]
+source: core/scss/ui/forms/_form-number-input.scss
+css-vars: number-input
+classnames:
+ component:
+ - class: number-input
+ desc: The box around the field and the two buttons; rendered by the plugin, or written by hand for custom buttons
+ size:
+ - class: number-input-sm
+ desc: Smaller field and buttons
+ - class: number-input-lg
+ desc: Larger field and buttons
+---
+
+import Example from '@components/Example.astro'
+import CodeDocs from '@components/CodeDocs.astro'
+import Icon from '@ui/Icon.astro'
+
+## Overview
+
+A number input is an ordinary `` between two buttons. The input stays the only field a form sees: the buttons, the arrow keys and the JavaScript API all write to it, keep the value between `min` and `max` and round it to the precision of `step`.
+
+Add `data-bs-toggle="number-input"` to the input and Tabler does the rest: it wraps the field in a `.number-input` box and renders the two buttons. The bounds come from the input's own `min`, `max` and `step` attributes.
+
+
+
+
+
+## Usage
+
+### Bounds and step
+
+The button at a bound goes disabled, so the user sees when the value cannot go further. Letters never get into the field: a typed one is dropped and a pasted text keeps only its digits, with a minus sign allowed only when `min` is negative and a decimal separator only for a fractional `step`. A typed value is clamped
+once the field is left. A fractional `step` sets the precision: with `step="0.25"` the value is always shown with two decimals.
+
+
+
+
+
+Without `min` and `max` the value has no bounds. An empty field steps from `min` when that is positive, otherwise from zero.
+
+### Text or number input
+
+A `type="text"` input gets `role="spinbutton"`, the matching `aria-value*` attributes and a numeric `inputmode`, so a phone shows the number keyboard and the native spin buttons never appear. A `type="number"` input keeps the browser's own validation and keyboard behaviour; the number input only hides its spin buttons.
+
+
+
+
+
+### Keyboard
+
+With the focus in the field, ↑ and ↓ step the value, Home and End jump to `min` and `max`. Holding a button down keeps stepping after a short delay; set `data-bs-repeat="false"` to turn that off.
+
+### Sizes
+
+Add `.form-control-sm` or `.form-control-lg` to the input; the rendered box gets `.number-input-sm` or `.number-input-lg`, so the field and the buttons keep one height.
+
+
+
+
+
+
+
+
+
+
+
+### Wider input
+
+The input is `3.5rem` wide by default, enough for a quantity. Set `--tblr-number-input-input-width` on the input for longer values.
+
+
+
+
+
+### Disabled
+
+Add `disabled` or `readonly` to the input; both buttons follow.
+
+
+
+
+
+### Validation
+
+Add `.is-valid` or `.is-invalid` to the input, the same way you would on any `.form-control`. The field is too narrow for the validation icon, so only the border changes colour. A `.valid-feedback` or `.invalid-feedback` placed right after the input still shows, even though the plugin puts its box between them.
+
+
+
+
+
In stock, ships today.
+
+
+
+
Only 5 pieces left.
+
+
+
+With a `type="number"` input the browser validates `required` itself (the plugin keeps the value inside `min` and `max` anyway), so a `.was-validated` form colours the box without any class on the input.
+
+
+
+
+
+### Custom buttons
+
+To change the icons, the labels or the classes of the buttons, write the markup yourself: put `data-bs-toggle="number-input"` on a `.number-input` wrapper and mark the buttons with `data-bs-number-input-action="decrement"` and `"increment"`. An empty button still gets the default icon from CSS; any content inside replaces it.
+
+
+
+
+
+
+
+
+
+## Options
+
+Options are read from `data-bs-*` attributes on the element that carries `data-bs-toggle` (the `data-tblr-*` alias also works), or passed to the constructor. An option set this way wins over the input's attribute.
+
+| Option | Type | Default | Description |
+| ---------------- | -------------- | -------------- | ---------------------------------------------------------------------------------------------------- |
+| `min` | number or null | `null` | Lower bound. When `null`, the input's `min` attribute is used; without that there is no lower bound. |
+| `max` | number or null | `null` | Upper bound. When `null`, the input's `max` attribute is used; without that there is no upper bound. |
+| `step` | number or null | `null` | Amount per step. When `null`, the input's `step` attribute is used, or `1`. |
+| `repeat` | boolean | `true` | Keeps stepping while a button is held down. |
+| `repeatDelay` | number | `400` | Milliseconds a button has to be held before it starts repeating. |
+| `repeatInterval` | number | `80` | Milliseconds between two repeated steps. |
+| `decrementLabel` | string | `\'Decrease\'` | `aria-label` of the rendered decrease button. |
+| `incrementLabel` | string | `\'Increase\'` | `aria-label` of the rendered increase button. |
+
+## JavaScript
+
+Tabler creates a `NumberInput` for every element matching `[data-bs-toggle="number-input"]` (or `[data-tblr-toggle="number-input"]`), an input or a `.number-input` wrapper:
+
+
+
+Create one manually, or get an existing instance:
+
+```javascript
+const el = document.querySelector('#quantity')
+const numberInput = tabler.NumberInput.getOrCreateInstance(el, { min: 1, max: 10 })
+```
+
+The input itself fires the usual `input` and `change` events whenever a button or the API changes it, so a form, a framework binding or validation sees a stepped value like a typed one. On top of that, `change.bs.numberInput` fires on the element the plugin was created on with both values:
+
+```javascript
+el.addEventListener('change.bs.numberInput', (event) => {
+ console.log(event.value, event.previousValue)
+})
+```
+
+### Methods
+
+| Method | Description |
+| ----------------- | ------------------------------------------------------------------------ |
+| `getValue()` | Returns the current value as a number, or `null` for an empty field. |
+| `setValue(value)` | Sets the value, clamped to the bounds and rounded to the step precision. |
+| `increment()` | Adds one step. |
+| `decrement()` | Removes one step. |
+| `dispose()` | Removes the listeners and the instance. |
diff --git a/docs/content/ui/getting-started/color-modes.mdx b/docs/content/ui/getting-started/color-modes.mdx
index 026c119fe..a7ad90a92 100644
--- a/docs/content/ui/getting-started/color-modes.mdx
+++ b/docs/content/ui/getting-started/color-modes.mdx
@@ -15,11 +15,11 @@ Tabler includes a small script, [`tabler-theme.js`](https://github.com/tabler/ta
| Setting | Attribute | Default | Allowed values |
| --- | --- | --- | --- |
| Color mode | `data-bs-theme` | `auto` | `light`, `dark`, `auto` |
-| Base gray shade | `data-bs-theme-base` | `neutral` | `slate`, `gray`, `zinc`, `neutral`, `stone` |
+| Base gray shade | `data-bs-theme-base` | `neutral` | `slate`, `gray`, `zinc`, `neutral`, `stone`, `mist` |
| Font family | `data-bs-theme-font` | `sans-serif` | `sans-serif`, `serif`, `monospace`, `comic` |
| Primary color | `data-bs-theme-primary` | `blue` | `blue`, `azure`, `indigo`, `purple`, `pink`, `red`, `orange`, `yellow`, `lime`, `green`, `teal`, `cyan`, `inverted` |
| Corner radius | `data-bs-theme-radius` | `1` | `0`, `0.5`, `1`, `1.5`, `2` |
-| Navigation position | `data-bs-navbar-position` | `horizontal` | `horizontal`, `vertical` |
+| Navigation position | `data-bs-navbar-position` | `horizontal` | `horizontal`, `vertical`, `combined` |
| Container width | `data-bs-layout` | `default` | `default`, `fluid`, `boxed` |
| Navbar behavior | `data-bs-navbar` | `default` | `default`, `sticky` |
| Navigation theme | `data-bs-navbar-theme` | `default` | `default`, `dark`, `primary` |
@@ -35,7 +35,7 @@ Each setting only appears on `` when it differs from its default. For exam
The last five settings change the page layout rather than its colors, and they need the markup described in [Page layouts](/ui/layout/page-layouts):
-- `data-bs-navbar-position` only does something on a page that renders **both** navigations - a vertical `aside.navbar-vertical` and a horizontal navbar, both direct children of `.page`. The CSS shows one and hides the other. A page with a single navbar keeps it, whatever the stored value says, so a stale choice can never leave a page with no navigation.
+- `data-bs-navbar-position` only does something on a page that renders **both** navigations - a vertical `aside.navbar-vertical` and a horizontal navbar, both direct children of `.page`. The CSS shows one and hides the other, and `combined` keeps both on screen. A page with a single navbar keeps it, whatever the stored value says, so a stale choice can never leave a page with no navigation.
- `data-bs-layout` is the attribute form of the `.layout-fluid` and `.layout-boxed` body classes. Use one route or the other, not both.
- `data-bs-navbar="sticky"` makes the top navbar stick to the top of the viewport while the page scrolls. On the two-row navbar only the top row sticks.
- `data-bs-navbar-theme` colors whichever navigation the page shows, since the vertical sidebar is a navbar too. `default` means "follow the page color mode", not "always light". `dark` gives it the dark palette on a light page - the same result as putting `data-bs-theme="dark"` on the element in your markup. `primary` paints it in the primary color with the dark text palette.
diff --git a/docs/content/ui/getting-started/index.mdx b/docs/content/ui/getting-started/index.mdx
index 22cfbf8c6..e4643e440 100644
--- a/docs/content/ui/getting-started/index.mdx
+++ b/docs/content/ui/getting-started/index.mdx
@@ -13,5 +13,5 @@ Tabler is a UI kit for admin panels and dashboards, built on Bootstrap 5. It giv
You don't have to build the common parts of an app interface from scratch. Cards, tables, forms, navigation and the page layouts are ready, and each of them has a dark mode and an RTL version. Tabler is open source under the MIT license and is developed on GitHub, so you can report a bug, send a fix or read the source of any component.
-
-
+
+
diff --git a/docs/content/ui/getting-started/upgrade/1-5.mdx b/docs/content/ui/getting-started/upgrade/1-5.mdx
index 291727b30..7fc3693ff 100644
--- a/docs/content/ui/getting-started/upgrade/1-5.mdx
+++ b/docs/content/ui/getting-started/upgrade/1-5.mdx
@@ -3,7 +3,7 @@ title: Upgrade to 1.5
summary: Move a project from Tabler 1.4 to 1.5. Every breaking change is listed with a before and after example for markup, JavaScript and Sass.
description: Upgrade guide from Tabler 1.4 to 1.5 with all breaking changes, renamed classes and Sass updates.
added-in: '1.5.0'
-order: 2
+order: 3
related: [/ui/getting-started/rtl]
---
diff --git a/docs/content/ui/getting-started/upgrade/1-6.mdx b/docs/content/ui/getting-started/upgrade/1-6.mdx
index 3775c7d69..28c9ff835 100644
--- a/docs/content/ui/getting-started/upgrade/1-6.mdx
+++ b/docs/content/ui/getting-started/upgrade/1-6.mdx
@@ -3,7 +3,7 @@ title: Upgrade to 1.6
summary: Move a project from Tabler 1.5 to 1.6. Nothing breaks. The page lists what is deprecated, with a before and after example for CSS, JavaScript and Sass.
description: Upgrade guide from Tabler 1.5 to 1.6 with every deprecated class, color variable, Sass variable and JavaScript helper, and what to use instead.
added-in: '1.6.0'
-order: 1
+order: 2
related: [/ui/getting-started/upgrade/1-5, /ui/getting-started/customize]
---
diff --git a/docs/content/ui/getting-started/upgrade/1-7.mdx b/docs/content/ui/getting-started/upgrade/1-7.mdx
new file mode 100644
index 000000000..5a7fe3f50
--- /dev/null
+++ b/docs/content/ui/getting-started/upgrade/1-7.mdx
@@ -0,0 +1,142 @@
+---
+title: Upgrade to 1.7
+summary: Move a project from Tabler 1.6 to 1.7. The Pricing and Shape components moved from the marketing bundle into the core bundle, and buttons, switches, small controls, flags and a few dark mode surfaces look a little different.
+description: Upgrade guide from Tabler 1.6 to 1.7 with the components that moved from the marketing bundle into the core bundle, the visual changes to check, and what to change in your HTML and Sass imports.
+added-in: '1.7.0'
+order: 1
+related: [/ui/getting-started/upgrade/1-6, /ui/getting-started/customize]
+---
+
+Tabler 1.7 adds a lot and changes very little. Every class, custom property, Sass variable and function from 1.6 is still there. The one thing that can touch your code is where two components ship. Pricing and Shape moved from `tabler-marketing.css` into the core `tabler.css`. If a page loads both files, and every marketing page does, there's nothing to do.
+
+If you load `tabler-marketing.css` on its own, or import its Sass partials one by one, read [Pricing and Shape are in the core bundle](#pricing-and-shape-are-in-the-core-bundle). Then have a look at [Visual changes](#visual-changes). A few components are drawn a little differently now, and if you have custom styles sitting close to them, they may need a small adjustment.
+
+If you're coming from 1.5 or older, do the [1.6 upgrade](/ui/getting-started/upgrade/1-6) first.
+
+## Overview
+
+If you…|You need to…
+---|---
+Load `tabler.css` and `tabler-marketing.css` together|Nothing
+Load `tabler-marketing.css` without `tabler.css` on a page that uses `.pricing` or `.shape`|Add `tabler.css`, see [Pricing and Shape are in the core bundle](#pricing-and-shape-are-in-the-core-bundle)
+Import `scss/marketing/pricing` or `scss/marketing/shape` in your own Sass|Import `scss/ui/pricing` or `scss/ui/shape`, see [Sass changes](#sass-changes)
+Style buttons, switches, small inputs or flags yourself|Check them, see [Visual changes](#visual-changes)
+Use `.flag-country-afrun`, `-bq-bo`, `-bq-sa`, `-bq-se`, `-kn-sk` or `-unasur`|Nothing now, they're aliases; switch to the new codes before 2.0, see [Flags](#flags)
+
+## Update the package
+
+Install the new version:
+
+```sh
+npm install --save @tabler/core@1.7.0
+```
+
+Or update the CDN links:
+
+```html
+
+
+```
+
+## Pricing and Shape are in the core bundle
+
+The [Pricing](/ui/components/pricing) component (`.pricing` and its `.pricing-*` parts) and the [Shape](/ui/components/shape) component (`.shape` with its size and color modifiers) used to ship only in `tabler-marketing.css`. They're part of `tabler.css` now. So a dashboard can show plan cards, or an icon in a shape, without loading the marketing stylesheet. The class names and the markup haven't changed.
+
+`tabler-marketing.css` doesn't contain the two components any more. A page that loads both stylesheets keeps working. A page that loaded only `tabler-marketing.css` for them has to add `tabler.css` too. It needs it anyway, for the buttons, badges and icons inside them:
+
+```diff
+-
++
++
+```
+
+The pricing card border also follows the theme now. In 1.6 it was a fixed light gray, even in dark mode. In 1.7 it uses `--tblr-border-color`.
+
+## Visual changes
+
+Nothing in this section needs a change to your markup. The components below are just drawn a little differently in 1.7. If you have a custom style that sits next to one of them, or overrides one of its values, it may end up off by a pixel or two. Worth a quick look.
+
+### Buttons
+
+Filled buttons, so `.btn-primary` and the other `.btn-{color}` variants, have a thin border now, a faint top-to-bottom gradient and light inner edges. Outline buttons lose their shadow. The default `.btn` mixes its hover and active tint over `--tblr-bg-surface`, which keeps it opaque. In 1.6 it turned slightly see-through when it was sitting on a colored background.
+
+`.btn-xl` uses a `1.25rem` font with `1.75rem` icons and the large radius. In 1.6 it used the `h1` size and `2rem` icons, which was a bit much. `.btn-sm` has `0.75rem` of side padding instead of `0.5rem`.
+
+### Small controls are 32px tall
+
+`.form-control-sm`, `.form-select-sm`, `.btn-sm` and a small Tom Select are all `32px` tall now. In 1.6 the small input was `28px`, and the small select and the small Tom Select didn't match it anyway. The vertical padding of the small size, `$input-btn-padding-y-sm`, went from `0.3125rem` to `0.4375rem`. That's what makes the three line up in a toolbar. The large sizes didn't change.
+
+### Switch
+
+`.form-switch` has a filled gray track when it's off, with a white knob and an even gap around it. In 1.6 the off track was just an outline. The track color comes from the new `$form-switch-bg` variable. `$form-switch-color` is the knob, and it's plain white in both color modes now.
+
+### Flags
+
+The flags plugin ships the [Tabler Flags](https://github.com/tabler/tabler-flags) set now. That's 304 flags at a 5:4 ratio instead of 4:3, so a flag is a little narrower for the same height. Six flags got a new code. The old classes keep working as [aliases](/ui/plugins/flags#old-flag-codes), but they go away in 2.0.
+
+### Dark mode
+
+A round of contrast fixes changed a few surfaces on a dark page:
+
+- `--tblr-bg-surface-tertiary` is translucent, so card headers, card footers and striped table rows show on a dark card.
+- `.input-group-text` has the `--tblr-secondary-color` text color and a translucent background.
+- Card, alert, dropdown and table borders aren't too bright any more. `$border-color-translucent-dark` was applied to more elements than it should have been.
+- Nav links, list group headers, toast headers, breadcrumbs, the footer, `.form-control-light` and `.form-help` no longer use fixed grays.
+
+### Smaller changes
+
+- `.card-table` rounds only the cells in the four card corners. In 1.6 every row rounded its outer cells.
+- `.nav-segmented` icons follow the link color on hover, like the text.
+- Disabled Tom Select fields have rounded corners, and small and large ones a rounded focus ring.
+- The `.pricing` card border follows the theme, with `--tblr-border-color` instead of a fixed light gray in dark mode.
+
+## JavaScript
+
+`tabler.js` is compiled for ES2022 instead of ES2015. Every [supported browser](/ui/getting-started/browser-support) has run ES2022 for years, so for a normal page nothing changes. It only matters if you run `tabler.js` through your own transpiler with an older target. In that case, either drop the override or let the transpiler lower the syntax for you.
+
+## Sass changes
+
+This only matters if you compile the `scss/` sources yourself and import partials one by one. `@use '@tabler/core/scss/tabler'` and `@use '@tabler/core/scss/tabler-marketing'` keep working as they are.
+
+### Moved partials
+
+Partial in 1.6|Partial in 1.7
+---|---
+`scss/marketing/_pricing.scss`|`scss/ui/_pricing.scss`
+`scss/marketing/_shape.scss`|`scss/ui/_shape.scss`
+
+```diff
+- @use '@tabler/core/scss/marketing/pricing';
+- @use '@tabler/core/scss/marketing/shape';
++ @use '@tabler/core/scss/ui/pricing';
++ @use '@tabler/core/scss/ui/shape';
+```
+
+## What is new in 1.7
+
+Besides the changes above, 1.7 adds:
+
+- [Map](/ui/components/map), an interactive map on MapLibre GL JS with markers, popups, routes, areas and a globe, from plain HTML with `data-bs-toggle="map"`. It ships as the `tabler-map.js` add-on.
+- [Vector map](/ui/plugins/vector-map), a world map as inline SVG with a value per country, markers, lines and a legend, in `tabler-vector-map.js`.
+- [Nav overflow](/ui/components/nav-overflow), a `.nav` that moves the items that don't fit into a More dropdown.
+- [Number input](/ui/forms/number-input), a quantity field with decrease and increase buttons, bounds, hold-to-repeat and keyboard support.
+- [Hover gallery](/ui/components/hover-gallery), several images in the space of one, switched by the pointer position.
+- [Image label](/ui/components/image-label), a short text or an icon pinned to a corner of an image.
+- [Mockup browser](/ui/components/mockup-browser), a browser window frame for a screenshot or a page preview.
+- [Icon list](/ui/components/icon-list), a list with icons instead of bullets.
+- [Text shimmer](/ui/components/text-shimmer), a band of light sweeping across a label while something runs.
+- [List group](/ui/components/list-group) items with media, content, meta and actions slots, selectable and nested items, and `.list-group-sm` and `.list-group-lg` sizes.
+- [Keyboard keys](/ui/base/typography#keyboard-input), with `` as a compact key cap, `.kbd-group` for a shortcut and `--tblr-kbd-*` variables.
+- [Datagrid](/ui/components/datagrid) with `.datagrid-horizontal` and `.datagrid-cols-{n}`.
+- [Datepicker](/ui/plugins/date-picker) with `weekNumbers`, `dateFormat` patterns such as `'DD.MM.YYYY'` or `'iso'`, and `boundInput` for ISO form values.
+- [Form CSS variables](/ui/forms/css-variables), with `--tblr-control-*` on inputs and selects, and their own sets for checks, switches, input group add-ons, floating labels and range inputs.
+- [Fill colors](/ui/base/colors#fill-colors), four neutral `--tblr-fill-*` tokens and the `.bg-fill-*` utilities behind badges, avatars, code and progress tracks.
+- A `mist` gray palette for `data-bs-theme-base`, see [Theme base colors](/ui/plugins/theme-base-colors).
+- `data-bs-navbar-position="combined"` to show the vertical and the horizontal navbar at once, see [Page layouts](/ui/layout/page-layouts#navigation-position).
+- [Pricing](/ui/components/pricing) and [Shape](/ui/components/shape) as documented core components, each with a class reference.
+- An [Icon design guide](/icons/design-guide) in the Tabler Icons docs.
+
+See the [changelog](https://github.com/tabler/tabler/releases) for the full list.
diff --git a/docs/content/ui/layout/page-layouts.mdx b/docs/content/ui/layout/page-layouts.mdx
index f067c20f3..b57559f84 100644
--- a/docs/content/ui/layout/page-layouts.mdx
+++ b/docs/content/ui/layout/page-layouts.mdx
@@ -470,10 +470,11 @@ Without the attribute, add the `offcanvas-floating` class to a single panel.
### Navigation position
-`data-bs-navbar-position` picks which navigation the page shows:
+`data-bs-navbar-position` picks which navigation the page shows. `horizontal` is the default, `vertical` shows the sidebar instead, and `combined` shows both at once:
```html
+
```
This one needs the markup to cooperate. Render **both** navigations as direct children of `.page`, the vertical `aside` first:
@@ -486,7 +487,7 @@ This one needs the markup to cooperate. Render **both** navigations as direct ch
```
-The CSS then shows one and hides the other, and removes the sidebar's width from the page offset when the sidebar is hidden. Both rules only fire when the other navigation really is on the page, so a page with a single navbar keeps it whatever the stored value says. That is what makes the layout safe to switch at runtime: no stored choice can leave a visitor with no navigation.
+The CSS then shows one and hides the other, and removes the sidebar's width from the page offset when the sidebar is hidden. `combined` opts out of both rules, so the sidebar and the top navbar stay on screen together, the way a page with both navbars rendered before 1.5. Both rules only fire when the other navigation really is on the page, so a page with a single navbar keeps it whatever the stored value says. That is what makes the layout safe to switch at runtime: no stored choice can leave a visitor with no navigation.
The demo pages use this: every page renders both navigations, and the layout demos state their own variant with the attribute.
diff --git a/docs/content/ui/plugins/advanced-select.mdx b/docs/content/ui/plugins/advanced-select.mdx
index 2615de98e..66be5e359 100644
--- a/docs/content/ui/plugins/advanced-select.mdx
+++ b/docs/content/ui/plugins/advanced-select.mdx
@@ -9,6 +9,7 @@ related: [/ui/forms/select-group, /ui/forms/elements]
---
import Example from '@components/Example.astro';
+import Icon from '@ui/Icon.astro';
import TabsPackage from '@components/TabsPackage.astro';
import { Code } from 'astro:components';
import { site } from '@shared/lib/site.ts';
@@ -146,6 +147,24 @@ Add `.is-valid` or `.is-invalid` to the `