diff --git a/.agents/agents/component-finisher.md b/.agents/agents/component-finisher.md new file mode 100644 index 000000000..213ed1148 --- /dev/null +++ b/.agents/agents/component-finisher.md @@ -0,0 +1,88 @@ +--- +name: component-finisher +description: Finishes a Tabler component or plugin whose core code already exists. Use after a new class, component or JS plugin has landed in `core/` (or a new `shared/ui` component was written) and the rest is still missing - the shared Astro component, the demo page, the docs page with its class reference, the menu entries and the screenshot page. Finds what is missing, writes it by following the repo skills, verifies it in the browser and reports. It never changes the public API in `core/`, and never commits or pushes. +--- + +You take a component whose styles or JavaScript already exist and make it visible and documented. The design and the API are decided; you do not redesign them. You write files, verify them and report. You never commit, push, open a PR or run a build while a dev server is running. + +Being called is an explicit request to write: do not stop to ask whether docs or a demo page are wanted, write them. + +## Skills are the source of truth + +Every step below has a skill in `.agents/skills//SKILL.md`. Read the skill file before you do the step and follow it; this file only says what to do and in which order. When this file and a skill disagree, the skill wins. + +## 1. Inventory + +Find out what the component is. The caller names it, or you derive it from the change: + +- `git status -sb`, `git diff origin/dev...HEAD --stat`, and `git diff` for uncommitted work. +- Styles: `core/scss/ui/_.scss` - list every class, modifier, size and custom property. +- JavaScript: `core/js/src/**` - list the options, `data-bs-*` attributes, events and public methods. A third-party library is listed in `core/libs.json`. +- Shared component: `shared/ui/.astro` and its `Props` interface. + +Then check each deliverable and build a gap table before writing anything: + +| Deliverable | Where to look | +| --- | --- | +| Shared component | `shared/ui/.astro` | +| Demo page | `preview/pages/.astro`, or a section on an existing page | +| Preview menu entry | `shared/data/menu.json`, and the page's `pageMenu` prop | +| Docs page | `docs/content/ui/**/.mdx` | +| Class reference | `classnames` and `source` in the docs page front matter | +| Docs menu entry | `shared/data/docs.json` | +| Demo to docs link | `` on the demo page | +| Screenshot page | `screenshots/pages/.astro` | +| Changeset | `.changeset/*.md` that names the component | + +A variant, size or option added to an existing component needs an update to the existing demo and docs pages, not new ones. Say in the report which case it is. + +## 2. Fill the gaps, in this order + +Later steps use the output of earlier ones, so keep the order. + +1. **Shared component** (`ui-component` skill; `astro-scripts` when it needs client-side JavaScript). Only when the markup is more than one element with classes, or when the demo page would repeat it. Extend an existing component before adding a new file. +2. **Demo page** (`demo-pages`, `page-layouts`). Show every variant, size, colour and state the inventory found. Compose from `@ui` components; fixed data only. +3. **Preview menu** (`navigation`). Add the entry with `"badge": "New"` for a new component; the url ends in `.html`; `pageMenu` equals the dot-path of the keys. +4. **Docs page** (`write-docs`). Simple English, the default section order, examples through `Example`. Set `added-in` to the next version when nearby pages use it. Quote any front matter value that contains `: `. +5. **Class reference** (`class-reference`). Every class from the inventory, generated families collapsed, `source` set. Check that every listed class exists in the SCSS. +6. **Docs menu and links** (`navigation`). Entry in `docs.json` with an absolute url without extension; `` on the demo page; `related:` on sibling docs pages when it helps. +7. **Screenshot page** (`screenshots`). Only for a new component or plugin, not for a variant. Write the page with the interesting state visible on load. Capture the PNG files only when no dev server is running, because `capture` starts a build; otherwise leave the page and say so. +8. **Changeset** (`generate-changeset`). Write the file only when the caller asked for one. Otherwise put the proposed packages, bump levels and the one-line text in the report. + +## 3. Limits + +- **Do not change public API.** Classes, custom properties, Sass variables, `data-*` attributes and JS options in `core/` stay as they are. When the demo or docs show that something is missing or broken (a variant without a class, an option that does nothing, a dark mode or RTL defect), write it down for the report and continue. Fix it only when the caller asked you to. +- **Do not invent.** Document what the source does. No class, prop or option that you did not find in the inventory. +- **Do not hand-write styles.** No `` + +const links = (count: number, extra: Record = {}): string => Array.from({ length: count }, (_, index) => extra[index] ?? ``).join('') + +const nav = (width: number, items: string, attributes = ''): string => `${STYLE}` + +describe('NavOverflow', () => { + let fixtureEl: HTMLElement + + beforeAll(() => { + fixtureEl = getFixture() + }) + + afterEach(() => { + clearFixture() + }) + + const wrapper = (): HTMLElement => fixtureEl.querySelector('.nav-overflow')! + const hidden = (): HTMLElement[] => [...fixtureEl.querySelectorAll('.nav-overflow > ul > .nav-item[data-bs-nav-overflow="true"]')] + const menuItems = (): HTMLElement[] => [...fixtureEl.querySelectorAll('.nav-overflow-menu > .dropdown-item')] + const toggleItem = (): HTMLElement => fixtureEl.querySelector('.nav-overflow-item')! + + describe('Default', () => { + it('should return plugin default config', () => { + expect(NavOverflow.Default).toEqual( + expect.objectContaining({ + collapseBelow: 0, + iconPlacement: 'start', + menuPlacement: 'bottom-end', + moreText: 'More', + threshold: 0, + }), + ) + }) + }) + + describe('DATA_KEY', () => { + it('should return plugin data key', () => { + expect(NavOverflow.DATA_KEY).toEqual('bs.navoverflow') + }) + }) + + describe('constructor', () => { + it('should accept a selector or an element', () => { + fixtureEl.innerHTML = nav(1000, links(3)) + + const bySelector = new NavOverflow('.nav-overflow') + expect(bySelector._element).toEqual(wrapper()) + bySelector.dispose() + + const byElement = new NavOverflow(wrapper()) + expect(byElement._element).toEqual(wrapper()) + }) + + it('should throw when the wrapper has no child nav', () => { + fixtureEl.innerHTML = '' + + expect(() => new NavOverflow(wrapper())).toThrowError(TypeError) + }) + + it('should add the wrapper and initialized classes', () => { + fixtureEl.innerHTML = nav(1000, links(3)).replace('class="nav-overflow"', '') + + const element = fixtureEl.querySelector('div')! + new NavOverflow(element) + + expect(element).toHaveClass('nav-overflow') + expect(element).toHaveClass('nav-overflow-initialized') + }) + + it('should create a dropdown toggle and menu inside the nav', () => { + fixtureEl.innerHTML = nav(1000, links(3)) + + new NavOverflow(wrapper()) + + const toggle = fixtureEl.querySelector('.nav > .nav-overflow-item > .nav-overflow-toggle')! + expect(toggle.getAttribute('data-bs-toggle')).toEqual('dropdown') + expect(toggle.nextElementSibling).toHaveClass('dropdown-menu') + expect(toggle.nextElementSibling).toHaveClass('nav-overflow-menu') + expect(toggle.nextElementSibling).toHaveClass('dropdown-menu-end') + }) + + it('should add the wrapper class and remove it on dispose when it was missing', () => { + fixtureEl.innerHTML = `${STYLE}
` + + const bare = fixtureEl.querySelector('#bare')! + const navOverflow = new NavOverflow(bare) + expect(bare).toHaveClass('nav-overflow') + + navOverflow.dispose() + expect(bare).not.toHaveClass('nav-overflow') + }) + }) + + describe('overflow', () => { + it('should hide the toggle when every item fits', () => { + fixtureEl.innerHTML = nav(1000, links(5)) + + new NavOverflow(wrapper()) + + expect(hidden()).toHaveLength(0) + expect(toggleItem()).toHaveClass('d-none') + }) + + it('should not reserve room for the toggle when every item fits without it', () => { + fixtureEl.innerHTML = nav(520, links(5)) + + new NavOverflow(wrapper()) + + expect(hidden()).toHaveLength(0) + expect(toggleItem()).toHaveClass('d-none') + }) + + it('should move items that do not fit into the menu', () => { + fixtureEl.innerHTML = nav(360, links(6)) + + new NavOverflow(wrapper()) + + expect(hidden().map((item) => item.textContent)).toEqual(['Link 4', 'Link 5', 'Link 6']) + expect(menuItems().map((item) => item.textContent)).toEqual(['Link 4', 'Link 5', 'Link 6']) + expect(toggleItem()).not.toHaveClass('d-none') + }) + + it('should collapse a .navbar-nav', () => { + fixtureEl.innerHTML = nav(360, links(6)).replace('class="nav"', 'class="navbar-nav"') + + new NavOverflow(wrapper()) + + expect(hidden()).toHaveLength(3) + }) + + it('should not collapse a vertical nav', () => { + fixtureEl.innerHTML = nav(360, links(6)).replace('class="nav"', 'class="nav" style="flex-direction: column"') + + new NavOverflow(wrapper()) + + expect(hidden()).toHaveLength(0) + expect(toggleItem()).toHaveClass('d-none') + }) + + it('should copy the active state from the nav item', () => { + fixtureEl.innerHTML = nav(160, links(3, { 2: '' })) + + new NavOverflow(wrapper()) + + expect(menuItems().at(-1)).toHaveClass('active') + }) + + it('should keep nav-overflow-keep items visible', () => { + fixtureEl.innerHTML = nav(360, links(6, { 5: '' })) + + new NavOverflow(wrapper()) + + expect(fixtureEl.querySelector('.nav-overflow-keep')).not.toHaveClass('d-none') + expect(menuItems().map((item) => item.textContent)).toEqual(['Link 3', 'Link 4', 'Link 5']) + }) + + it('should preserve active and disabled states', () => { + fixtureEl.innerHTML = nav( + 160, + links(3, { + 1: '', + 2: '', + }), + ) + + new NavOverflow(wrapper()) + + const [active, disabled] = menuItems() + expect(active).toHaveClass('dropdown-item') + expect(active).toHaveClass('active') + expect(active.hasAttribute('id')).toBe(false) + expect(disabled).toHaveClass('disabled') + }) + + it('should fire the overflow event with counts', () => { + fixtureEl.innerHTML = nav(360, links(6)) + + const listener = vi.fn() + wrapper().addEventListener('overflow.bs.navoverflow', listener) + new NavOverflow(wrapper()) + + const event = listener.mock.calls[0][0] as Event & { overflowCount: number; visibleCount: number } + expect(event.overflowCount).toEqual(3) + expect(event.visibleCount).toEqual(3) + }) + + it('should restore items when the wrapper grows', () => { + fixtureEl.innerHTML = nav(360, links(6)) + + const navOverflow = new NavOverflow(wrapper()) + expect(hidden()).toHaveLength(3) + + wrapper().style.width = '1000px' + navOverflow.update() + + expect(hidden()).toHaveLength(0) + expect(menuItems()).toHaveLength(0) + }) + + it('should fire the update event', () => { + fixtureEl.innerHTML = nav(1000, links(3)) + + const listener = vi.fn() + wrapper().addEventListener('update.bs.navoverflow', listener) + new NavOverflow(wrapper()).update() + + expect(listener).toHaveBeenCalledTimes(1) + }) + + it('should recalculate when the wrapper is resized', async () => { + fixtureEl.innerHTML = nav(1000, links(6)) + + new NavOverflow(wrapper()) + expect(hidden()).toHaveLength(0) + + wrapper().style.width = '360px' + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))) + + expect(hidden()).toHaveLength(3) + }) + + it('should keep an open menu open when nothing moves', () => { + fixtureEl.innerHTML = nav(360, links(6)) + + const navOverflow = new NavOverflow(wrapper()) + const toggle = fixtureEl.querySelector('.nav-overflow-toggle')! + toggle.click() + expect(fixtureEl.querySelector('.nav-overflow-menu')).toHaveClass('show') + + wrapper().style.height = '200px' + wrapper().style.width = '365px' + navOverflow.update() + + expect(fixtureEl.querySelector('.nav-overflow-menu')).toHaveClass('show') + expect(toggle.getAttribute('aria-expanded')).toEqual('true') + expect(hidden()).toHaveLength(3) + expect(menuItems()).toHaveLength(3) + }) + + it('should rebuild the menu when the overflowing items change', () => { + fixtureEl.innerHTML = nav(360, links(6)) + + const navOverflow = new NavOverflow(wrapper()) + fixtureEl.querySelector('.nav-overflow-toggle')!.click() + + wrapper().style.width = '460px' + navOverflow.update() + + expect(fixtureEl.querySelector('.nav-overflow-menu')).not.toHaveClass('show') + expect(menuItems().map((item) => item.textContent)).toEqual(['Link 5', 'Link 6']) + }) + + it('should reuse a toggle written in the markup', () => { + fixtureEl.innerHTML = nav(360, links(6) + '') + + const navOverflow = new NavOverflow(wrapper()) + + expect(fixtureEl.querySelectorAll('.nav-overflow-toggle')).toHaveLength(1) + expect(menuItems()).toHaveLength(3) + + navOverflow.dispose() + expect(fixtureEl.querySelector('.nav-overflow-toggle')).not.toBeNull() + }) + }) + + describe('config', () => { + it('should keep a minimum number of visible items with threshold', () => { + fixtureEl.innerHTML = nav(100, links(6)) + + new NavOverflow(wrapper(), { threshold: 2 }) + + expect(hidden()).toHaveLength(4) + }) + + it('should drop the text and label the toggle when moreText is false', () => { + fixtureEl.innerHTML = nav(1000, links(3)) + + new NavOverflow(wrapper(), { moreText: false }) + + const toggle = fixtureEl.querySelector('.nav-overflow-toggle')! + expect(toggle.querySelector('.nav-overflow-text')).toBeNull() + expect(toggle.getAttribute('aria-label')).toEqual('More') + }) + + it('should read moreText from data attributes', () => { + fixtureEl.innerHTML = nav(1000, links(3), 'data-bs-more-text="See all"') + + new NavOverflow(wrapper()) + + const toggle = fixtureEl.querySelector('.nav-overflow-toggle')! + expect(toggle.querySelector('.nav-overflow-text')!.textContent).toEqual('See all') + expect(toggle.hasAttribute('aria-label')).toBe(false) + }) + + it('should treat moreText as plain text', () => { + fixtureEl.innerHTML = nav(1000, links(3)) + + new NavOverflow(wrapper(), { moreText: '' }) + + expect(fixtureEl.querySelector('.nav-overflow-text img')).toBeNull() + }) + + it('should place the icon after the text when iconPlacement is end', () => { + fixtureEl.innerHTML = nav(1000, links(3)) + + new NavOverflow(wrapper(), { iconPlacement: 'end' }) + + const toggle = fixtureEl.querySelector('.nav-overflow-toggle')! + expect(toggle.firstElementChild).toHaveClass('nav-overflow-text') + expect(toggle.lastElementChild).toHaveClass('nav-overflow-icon') + }) + + it('should sanitize moreIcon', () => { + fixtureEl.innerHTML = nav(1000, links(3)) + + new NavOverflow(wrapper(), { moreIcon: '' }) + + const icon = fixtureEl.querySelector('.nav-overflow-icon')! + expect(icon.querySelector('script')).toBeNull() + expect(icon.querySelector('svg')!.hasAttribute('onload')).toBe(false) + expect(icon.querySelector('svg')!.getAttribute('viewBox')).toEqual('0 0 24 24') + }) + + it('should use a child [data-bs-overflow-icon] element as the icon', () => { + fixtureEl.innerHTML = nav(1000, links(3)).replace('', '') + + new NavOverflow(wrapper(), { moreIcon: '' }) + + const icon = fixtureEl.querySelector('.nav-overflow-icon > svg')! + expect(icon).toHaveClass('custom-icon') + expect(icon.hasAttribute('data-bs-overflow-icon')).toBe(false) + expect(icon.hasAttribute('onclick')).toBe(false) + expect(fixtureEl.querySelectorAll('.custom-icon')).toHaveLength(1) + }) + + it('should map menuPlacement to dropdown classes', () => { + fixtureEl.innerHTML = nav(1000, links(3)) + + new NavOverflow(wrapper(), { menuPlacement: 'top-start' }) + + expect(toggleItem()).toHaveClass('dropup') + expect(fixtureEl.querySelector('.nav-overflow-menu')).not.toHaveClass('dropdown-menu-end') + }) + + it('should pass menuStrategy to the overflow dropdown', () => { + fixtureEl.innerHTML = nav(360, links(6)) + + new NavOverflow(wrapper(), { menuStrategy: 'fixed' }) + + const dropdown = Dropdown.getInstance(fixtureEl.querySelector('.nav-overflow-toggle')!) as Dropdown + expect(dropdown._config.popperConfig).toEqual({ strategy: 'fixed' }) + }) + }) + + describe('collapseBelow', () => { + it('should collapse every item below a pixel value', () => { + fixtureEl.innerHTML = nav(600, links(3)) + + new NavOverflow(wrapper(), { collapseBelow: 700 }) + + expect(hidden()).toHaveLength(3) + }) + + it('should not collapse above a pixel value', () => { + fixtureEl.innerHTML = nav(600, links(3)) + + new NavOverflow(wrapper(), { collapseBelow: 500 }) + + expect(hidden()).toHaveLength(0) + }) + + it('should resolve a breakpoint name from --tblr-breakpoint-{name}', () => { + document.documentElement.style.setProperty('--tblr-breakpoint-md', '768px') + fixtureEl.innerHTML = nav(600, links(3), 'data-bs-collapse-below="md"') + + new NavOverflow(wrapper()) + + expect(hidden()).toHaveLength(3) + document.documentElement.style.removeProperty('--tblr-breakpoint-md') + }) + + it('should keep nav-overflow-keep items when collapsing all', () => { + fixtureEl.innerHTML = nav(600, links(3, { 0: '' })) + + new NavOverflow(wrapper(), { collapseBelow: 700 }) + + expect(hidden()).toHaveLength(2) + }) + }) + + describe('dropdowns', () => { + const DROPDOWN_ITEM = '' + + it('should move an overflowing dropdown into the menu as a nested dropstart', () => { + fixtureEl.innerHTML = nav(260, links(4, { 3: DROPDOWN_ITEM })) + + new NavOverflow(wrapper()) + + const submenu = fixtureEl.querySelector('.nav-overflow-menu > .dropstart')! + const toggle = submenu.firstElementChild as HTMLElement + expect(toggle).toHaveClass('dropdown-item') + expect(toggle).toHaveClass('dropdown-toggle') + expect(toggle.getAttribute('data-bs-toggle')).toEqual('dropdown') + expect(toggle.getAttribute('data-bs-auto-close')).toEqual('outside') + expect(toggle.getAttribute('role')).toEqual('button') + expect(toggle.hasAttribute('href')).toBe(false) + expect(submenu.querySelector('#products-menu')).not.toBeNull() + + const overflow = Dropdown.getInstance(fixtureEl.querySelector('.nav-overflow-toggle')!) as Dropdown + expect(overflow._config.autoClose).toEqual('outside') + }) + + it('should open submenus towards the end when the menu is start-aligned', () => { + fixtureEl.innerHTML = nav(260, links(4, { 3: DROPDOWN_ITEM })) + + new NavOverflow(wrapper(), { menuPlacement: 'bottom-start' }) + + expect(fixtureEl.querySelector('.nav-overflow-menu > .dropend')).not.toBeNull() + expect(fixtureEl.querySelector('.nav-overflow-menu > .dropstart')).toBeNull() + }) + + it('should open the nested dropdown from the overflow menu', () => { + fixtureEl.innerHTML = nav(260, links(4, { 3: DROPDOWN_ITEM })) + + new NavOverflow(wrapper()) + + const toggle = fixtureEl.querySelector('.nav-overflow-menu > .dropstart > .dropdown-toggle')! + toggle.click() + + expect(fixtureEl.querySelector('#products-menu')).toHaveClass('show') + }) + + it('should move the menu back to its nav item on restore', () => { + fixtureEl.innerHTML = nav(260, links(4, { 3: DROPDOWN_ITEM })) + + const navOverflow = new NavOverflow(wrapper()) + wrapper().style.width = '1000px' + navOverflow.update() + + const menu = fixtureEl.querySelector('#products-menu')! + expect(menu.previousElementSibling!.id).toEqual('products') + expect(fixtureEl.querySelector('.nav-overflow-menu .dropstart')).toBeNull() + }) + }) + + describe('proxy links', () => { + const tabs = (): string => links(6, { 5: '' }) + + it('should send a click on a moved plugin link to the original', () => { + fixtureEl.innerHTML = nav(360, tabs()) + + new NavOverflow(wrapper()) + const original = fixtureEl.querySelector('#last')! + const spy = vi.fn() + original.addEventListener('click', spy) + + const cloned = menuItems().at(-1)! + expect(cloned.hasAttribute('data-bs-toggle')).toBe(false) + + cloned.click() + expect(spy).toHaveBeenCalledTimes(1) + }) + + it('should copy the active state to the menu after the click', () => { + fixtureEl.innerHTML = nav(360, tabs()) + + new NavOverflow(wrapper()) + const original = fixtureEl.querySelector('#last')! + original.addEventListener('click', () => original.classList.add('active')) + + const cloned = menuItems().at(-1)! + cloned.click() + + expect(cloned).toHaveClass('active') + }) + + it('should keep plain links as they are', () => { + fixtureEl.innerHTML = nav(360, links(6)) + + new NavOverflow(wrapper()) + + expect(menuItems().at(-1)!.getAttribute('href')).toEqual('#') + }) + }) + + describe('jQueryInterface', () => { + it('should create an instance and call a method', () => { + fixtureEl.innerHTML = nav(1000, links(3)) + + const element = wrapper() + const collection = { each: (callback: (this: HTMLElement) => void) => callback.call(element) } + + NavOverflow.jQueryInterface.call(collection as never, 'update') + + expect(NavOverflow.getInstance(element)).not.toBeNull() + expect(() => NavOverflow.jQueryInterface.call(collection as never, 'nope')).toThrow(TypeError) + }) + }) + + describe('dispose', () => { + it('should restore items and remove the generated toggle', () => { + fixtureEl.innerHTML = nav(360, links(6)) + + const navOverflow = new NavOverflow(wrapper()) + navOverflow.dispose() + + expect(hidden()).toHaveLength(0) + expect(fixtureEl.querySelector('.d-none')).toBeNull() + expect(fixtureEl.querySelector('.nav-overflow-item')).toBeNull() + expect(NavOverflow.getInstance(wrapper())).toBeNull() + }) + + it('should disconnect the ResizeObserver', () => { + fixtureEl.innerHTML = nav(1000, links(3)) + + const navOverflow = new NavOverflow(wrapper()) + const spy = vi.spyOn(navOverflow._resizeObserver!, 'disconnect') + navOverflow.dispose() + + expect(spy).toHaveBeenCalled() + }) + }) + + describe('getOrCreateInstance', () => { + it('should return the existing instance', () => { + fixtureEl.innerHTML = nav(1000, links(3)) + + const navOverflow = new NavOverflow(wrapper()) + + expect(NavOverflow.getOrCreateInstance(wrapper())).toEqual(navOverflow) + }) + }) +}) diff --git a/core/js/tests/unit/number-input.spec.ts b/core/js/tests/unit/number-input.spec.ts new file mode 100644 index 000000000..05f8ce013 --- /dev/null +++ b/core/js/tests/unit/number-input.spec.ts @@ -0,0 +1,473 @@ +import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest' +import { clearFixture, getFixture } from '../helpers/fixture' +import NumberInput from '../../src/number-input' + +const markup = (inputAttributes = 'value="1" min="0" max="5"', wrapperAttributes = ''): string => + [ + `
`, + ' ', + ` `, + ' ', + '
', + ].join('') + +describe('NumberInput', () => { + let fixtureEl: HTMLElement + + beforeAll(() => { + fixtureEl = getFixture() + }) + + beforeEach(() => { + fixtureEl.innerHTML = markup() + }) + + afterEach(() => { + clearFixture() + vi.useRealTimers() + }) + + const numberInput = (): HTMLElement => fixtureEl.querySelector('.number-input')! + const input = (): HTMLInputElement => fixtureEl.querySelector('input')! + const decrementButton = (): HTMLButtonElement => fixtureEl.querySelector('[data-bs-number-input-action="decrement"]')! + const incrementButton = (): HTMLButtonElement => fixtureEl.querySelector('[data-bs-number-input-action="increment"]')! + + const change = (value: string): void => { + input().value = value + input().dispatchEvent(new Event('change', { bubbles: true })) + } + + const keydown = (key: string): KeyboardEvent => { + const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }) + input().dispatchEvent(event) + return event + } + + describe('NAME', () => { + it('should return the plugin name', () => { + expect(NumberInput.NAME).toBe('numberInput') + }) + }) + + describe('Default', () => { + it('should expose the default config', () => { + expect(NumberInput.Default.min).toBeNull() + expect(NumberInput.Default.repeat).toBe(true) + expect(typeof NumberInput.DefaultType.step).toBe('string') + }) + }) + + describe('constructor', () => { + it('should read the bounds from the input attributes', () => { + const instance = new NumberInput(numberInput()) + expect(instance._min).toBe(0) + expect(instance._max).toBe(5) + expect(instance._step).toBe(1) + }) + + it('should let the config override the input attributes', () => { + const instance = new NumberInput(numberInput(), { min: 2, max: 10, step: 2 }) + expect(instance._min).toBe(2) + expect(instance._max).toBe(10) + expect(instance._step).toBe(2) + expect(input().value).toBe('2') + }) + + it('should read the config from data attributes', () => { + fixtureEl.innerHTML = markup('value="1"', 'data-bs-min="1" data-bs-max="3" data-bs-step="0.5"') + const instance = new NumberInput(numberInput()) + expect(instance._min).toBe(1) + expect(instance._max).toBe(3) + expect(instance._step).toBe(0.5) + }) + + it('should have no bounds when neither attributes nor config set them', () => { + fixtureEl.innerHTML = markup('value="1"') + const instance = new NumberInput(numberInput()) + expect(instance._min).toBe(Number.NEGATIVE_INFINITY) + expect(instance._max).toBe(Number.POSITIVE_INFINITY) + }) + + it('should fall back to a step of 1 for a zero or negative step', () => { + fixtureEl.innerHTML = markup('value="1" step="0"') + expect(new NumberInput(numberInput())._step).toBe(1) + }) + + it('should clamp the initial value', () => { + fixtureEl.innerHTML = markup('value="9" min="0" max="5"') + new NumberInput(numberInput()) + expect(input().value).toBe('5') + }) + + it('should give a text input the spinbutton role and a numeric inputmode', () => { + new NumberInput(numberInput()) + expect(input().getAttribute('role')).toBe('spinbutton') + expect(input().getAttribute('inputmode')).toBe('numeric') + expect(input().getAttribute('aria-valuemin')).toBe('0') + expect(input().getAttribute('aria-valuemax')).toBe('5') + expect(input().getAttribute('aria-valuenow')).toBe('1') + }) + + it('should use the decimal inputmode for a fractional step', () => { + fixtureEl.innerHTML = markup('value="1" step="0.25"') + new NumberInput(numberInput()) + expect(input().getAttribute('inputmode')).toBe('decimal') + }) + + it('should leave a number input alone', () => { + fixtureEl.innerHTML = markup('type="number" value="1" min="0" max="5"').replace('type="text"', '') + new NumberInput(numberInput()) + expect(input().hasAttribute('role')).toBe(false) + expect(input().hasAttribute('aria-valuenow')).toBe(false) + }) + + it('should do nothing without an input', () => { + fixtureEl.innerHTML = '
' + const instance = new NumberInput(numberInput()) + expect(instance._input).toBeNull() + expect(instance.getValue()).toBeNull() + }) + + it('should disable the buttons of a disabled input', () => { + fixtureEl.innerHTML = markup('value="1" disabled') + new NumberInput(numberInput()) + expect(decrementButton().disabled).toBe(true) + expect(incrementButton().disabled).toBe(true) + }) + }) + + describe('buttons', () => { + it('should step the value on click', () => { + new NumberInput(numberInput()) + incrementButton().click() + expect(input().value).toBe('2') + decrementButton().click() + decrementButton().click() + expect(input().value).toBe('0') + }) + + it('should disable the button at a bound', () => { + fixtureEl.innerHTML = markup('value="4" min="0" max="5"') + new NumberInput(numberInput()) + expect(incrementButton().disabled).toBe(false) + incrementButton().click() + expect(input().value).toBe('5') + expect(incrementButton().disabled).toBe(true) + expect(decrementButton().disabled).toBe(false) + }) + + it('should never leave the bounds', () => { + const instance = new NumberInput(numberInput(), { max: 2 }) + instance.increment() + instance.increment() + instance.increment() + expect(input().value).toBe('2') + }) + + it('should keep stepping while a button is held', () => { + vi.useFakeTimers() + new NumberInput(numberInput(), { max: 100 }) + + incrementButton().dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, pointerType: 'mouse' })) + vi.advanceTimersByTime(400) + expect(input().value).toBe('2') + vi.advanceTimersByTime(80 * 3) + expect(input().value).toBe('5') + + incrementButton().dispatchEvent(new PointerEvent('pointerup', { bubbles: true })) + vi.advanceTimersByTime(1000) + expect(input().value).toBe('5') + + // The click that ends the hold is not one more step + incrementButton().click() + expect(input().value).toBe('5') + }) + + it('should stop a hold at a bound', () => { + vi.useFakeTimers() + const instance = new NumberInput(numberInput(), { max: 3 }) + + incrementButton().dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, pointerType: 'mouse' })) + vi.advanceTimersByTime(400 + 80 * 5) + expect(input().value).toBe('3') + expect(instance._repeatTimer).toBeNull() + }) + + it('should not repeat when the option is off', () => { + vi.useFakeTimers() + new NumberInput(numberInput(), { repeat: false, max: 100 }) + + incrementButton().dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, pointerType: 'mouse' })) + vi.advanceTimersByTime(2000) + expect(input().value).toBe('1') + }) + }) + + describe('keyboard', () => { + it('should step with the arrow keys and jump with Home and End', () => { + new NumberInput(numberInput()) + expect(keydown('ArrowUp').defaultPrevented).toBe(true) + expect(input().value).toBe('2') + keydown('ArrowDown') + expect(input().value).toBe('1') + keydown('End') + expect(input().value).toBe('5') + keydown('Home') + expect(input().value).toBe('0') + }) + + it('should ignore Home and End without a bound', () => { + fixtureEl.innerHTML = markup('value="1"') + new NumberInput(numberInput()) + expect(keydown('End').defaultPrevented).toBe(false) + expect(input().value).toBe('1') + }) + + it('should leave other keys alone', () => { + new NumberInput(numberInput()) + expect(keydown('a').defaultPrevented).toBe(false) + }) + }) + + describe('typed value', () => { + it('should clamp a typed value on change', () => { + new NumberInput(numberInput()) + change('42') + expect(input().value).toBe('5') + expect(incrementButton().disabled).toBe(true) + }) + + it('should clamp on blur as well', () => { + new NumberInput(numberInput()) + input().value = '125' + input().dispatchEvent(new Event('blur')) + expect(input().value).toBe('5') + }) + + it('should fire nothing when an untouched field is left', () => { + new NumberInput(numberInput()) + const spy = vi.fn() + input().addEventListener('change', spy) + input().dispatchEvent(new Event('blur')) + expect(spy).not.toHaveBeenCalled() + }) + + it('should restore the previous value after a non-numeric edit', () => { + new NumberInput(numberInput()) + change('3') + change('abc') + expect(input().value).toBe('3') + }) + + it('should accept a decimal comma', () => { + fixtureEl.innerHTML = markup('value="1" step="0.5"') + new NumberInput(numberInput()) + change('2,5') + expect(input().value).toBe('2.5') + }) + }) + + describe('character filter', () => { + const beforeInput = (data: string, inputType = 'insertText'): InputEvent => { + const event = new InputEvent('beforeinput', { data, inputType, bubbles: true, cancelable: true }) + input().dispatchEvent(event) + return event + } + + it('should drop a typed letter and keep a digit', () => { + new NumberInput(numberInput()) + expect(beforeInput('a').defaultPrevented).toBe(true) + expect(beforeInput('7').defaultPrevented).toBe(false) + expect(beforeInput('.').defaultPrevented).toBe(true) + expect(beforeInput('-').defaultPrevented).toBe(true) + }) + + it('should allow the sign and the separator when the bounds and step need them', () => { + fixtureEl.innerHTML = markup('value="0" min="-5" step="0.5"') + new NumberInput(numberInput()) + expect(beforeInput('-').defaultPrevented).toBe(false) + expect(beforeInput('.').defaultPrevented).toBe(false) + expect(beforeInput(',').defaultPrevented).toBe(false) + expect(beforeInput('e').defaultPrevented).toBe(true) + }) + + it('should keep only the digits of a pasted text', () => { + new NumberInput(numberInput()) + input().value = '' + input().setSelectionRange(0, 0) + expect(beforeInput('4 pcs', 'insertFromPaste').defaultPrevented).toBe(true) + expect(input().value).toBe('4') + }) + + it('should leave deletions alone', () => { + new NumberInput(numberInput()) + expect(beforeInput('', 'deleteContentBackward').defaultPrevented).toBe(false) + }) + }) + + describe('precision', () => { + it('should round to the precision of the step', () => { + fixtureEl.innerHTML = markup('value="0.1" step="0.1"') + const instance = new NumberInput(numberInput()) + instance.increment() + instance.increment() + expect(input().value).toBe('0.3') + expect(instance.getValue()).toBe(0.3) + }) + + it('should step an empty field from the lower bound', () => { + fixtureEl.innerHTML = markup('value="" min="2" max="9"') + const instance = new NumberInput(numberInput()) + expect(instance.getValue()).toBeNull() + expect(input().hasAttribute('aria-valuenow')).toBe(false) + instance.increment() + expect(input().value).toBe('3') + }) + }) + + describe('events', () => { + it('should fire change.bs.numberInput with the values', () => { + const instance = new NumberInput(numberInput()) + const spy = vi.fn() + numberInput().addEventListener('change.bs.numberInput', spy) + + instance.increment() + expect(spy).toHaveBeenCalledTimes(1) + const event = spy.mock.calls[0][0] as Event & { value: number; previousValue: number } + expect(event.value).toBe(2) + expect(event.previousValue).toBe(1) + }) + + it('should fire input and change on the field', () => { + const instance = new NumberInput(numberInput()) + const onInput = vi.fn() + const onChange = vi.fn() + input().addEventListener('input', onInput) + input().addEventListener('change', onChange) + + instance.increment() + expect(onInput).toHaveBeenCalledTimes(1) + expect(onChange).toHaveBeenCalledTimes(1) + }) + + it('should not fire when the value does not change', () => { + const instance = new NumberInput(numberInput()) + const spy = vi.fn() + numberInput().addEventListener('change.bs.numberInput', spy) + + instance.setValue(1) + instance.decrement() + instance.decrement() + expect(spy).toHaveBeenCalledTimes(1) + }) + }) + + describe('setValue', () => { + it('should set and clamp the value', () => { + const instance = new NumberInput(numberInput()) + instance.setValue(3) + expect(input().value).toBe('3') + instance.setValue('99') + expect(input().value).toBe('5') + instance.setValue('nope') + expect(input().value).toBe('5') + }) + }) + + describe('disabled', () => { + it('should not step a disabled or readonly input', () => { + fixtureEl.innerHTML = markup('value="1" readonly') + const instance = new NumberInput(numberInput()) + instance.increment() + expect(input().value).toBe('1') + expect(incrementButton().disabled).toBe(true) + }) + }) + + describe('dispose', () => { + it('should remove the listeners and the instance', () => { + const instance = new NumberInput(numberInput()) + instance.dispose() + incrementButton().click() + expect(input().value).toBe('1') + expect(NumberInput.getInstance(numberInput())).toBeNull() + }) + }) + + describe('bare input', () => { + const bare = (attributes = 'value="1" min="0" max="5"'): HTMLInputElement => { + fixtureEl.innerHTML = `
` + return fixtureEl.querySelector('input')! + } + + it('should wrap the input and render the two buttons', () => { + const el = bare() + new NumberInput(el) + + const wrapper = el.parentElement! + expect(wrapper.classList.contains('number-input')).toBe(true) + expect(wrapper.parentElement!.tagName).toBe('FORM') + expect([...wrapper.children].map((child) => child.tagName)).toEqual(['BUTTON', 'INPUT', 'BUTTON']) + expect(decrementButton().getAttribute('aria-label')).toBe('Decrease') + expect(incrementButton().getAttribute('aria-label')).toBe('Increase') + expect(incrementButton().type).toBe('button') + expect(incrementButton().innerHTML).toBe('') + }) + + it('should step through the rendered buttons', () => { + const el = bare() + new NumberInput(el) + incrementButton().click() + expect(el.value).toBe('2') + }) + + it('should read the config from the input and label the buttons', () => { + const el = bare('value="1" data-bs-max="2" data-bs-increment-label="More" data-bs-decrement-label="Fewer"') + new NumberInput(el) + expect(incrementButton().getAttribute('aria-label')).toBe('More') + expect(decrementButton().getAttribute('aria-label')).toBe('Fewer') + incrementButton().click() + incrementButton().click() + expect(el.value).toBe('2') + }) + + it('should map the size of the input to the box', () => { + fixtureEl.innerHTML = '' + const el = fixtureEl.querySelector('input')! + new NumberInput(el) + expect(el.parentElement!.classList.contains('number-input-sm')).toBe(true) + }) + + it('should fire change.bs.numberInput on the input', () => { + const el = bare() + const instance = new NumberInput(el) + const spy = vi.fn() + el.addEventListener('change.bs.numberInput', spy) + instance.increment() + expect(spy).toHaveBeenCalledTimes(1) + }) + + it('should unwrap the input on dispose', () => { + const el = bare() + new NumberInput(el).dispose() + expect(el.parentElement!.tagName).toBe('FORM') + expect(fixtureEl.querySelector('.number-input')).toBeNull() + expect(fixtureEl.querySelectorAll('button')).toHaveLength(0) + }) + }) + + describe('data-api', () => { + it('should initialise on getOrCreateInstance', () => { + const instance = NumberInput.getOrCreateInstance(numberInput()) + expect(instance).toBeInstanceOf(NumberInput) + expect(NumberInput.getOrCreateInstance(numberInput())).toBe(instance) + }) + + it('should accept the data-tblr-* action attributes', () => { + fixtureEl.innerHTML = markup().replaceAll('data-bs-number-input-action', 'data-tblr-number-input-action') + new NumberInput(numberInput()) + fixtureEl.querySelector('[data-tblr-number-input-action="increment"]')!.click() + expect(input().value).toBe('2') + }) + }) +}) diff --git a/core/js/tests/unit/tab.spec.ts b/core/js/tests/unit/tab.spec.ts index ba220201e..21ce407fd 100644 --- a/core/js/tests/unit/tab.spec.ts +++ b/core/js/tests/unit/tab.spec.ts @@ -296,7 +296,7 @@ describe('Tab', () => { fixtureEl.innerHTML = [''].join('') const tabEl = fixtureEl.querySelector('.nav-link')! - const tab = new Tab(tabEl) + new Tab(tabEl) const spyStop = vi.spyOn(Event.prototype, 'stopPropagation') const spyPrevent = vi.spyOn(Event.prototype, 'preventDefault') @@ -326,6 +326,7 @@ describe('Tab', () => { tabEl1.dispatchEvent(keydown) expect(spyFocus2).toHaveBeenCalled() + expect(spyFocus3).not.toHaveBeenCalled() }) it('should handle left/up arrow', () => { diff --git a/core/js/tests/unit/util/index.spec.ts b/core/js/tests/unit/util/index.spec.ts index dedc01720..214b194d2 100644 --- a/core/js/tests/unit/util/index.spec.ts +++ b/core/js/tests/unit/util/index.spec.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest' +import { describe, it, expect, beforeAll, afterEach, vi } from 'vitest' import { execute, executeAfterTransition, findShadowRoot, getElement, getNextActiveElement, getTransitionDurationFromElement, getUID, isDisabled, isElement, isRTL, isVisible, noop, parseSelector, reflow, toType, triggerTransitionEnd } from '../../../src/bootstrap/util/index' import { clearFixture, getFixture } from '../../helpers/fixture' 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 37e580c94..66bd1be57 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", "scripts": { @@ -17,12 +17,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:standalone": "concurrently \"terser dist/js/tabler.js --compress --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\" \"terser dist/js/tabler.esm.js --module --compress --mangle --source-map \\\"content=dist/js/tabler.esm.js.map,filename=dist/js/tabler.esm.min.js.map,url=tabler.esm.min.js.map\\\" -o dist/js/tabler.esm.min.js\"", - "js:min:theme": "concurrently \"terser dist/js/tabler-theme.js --compress --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\" \"terser dist/js/tabler-theme.esm.js --module --compress --mangle --source-map \\\"content=dist/js/tabler-theme.esm.js.map,filename=dist/js/tabler-theme.esm.min.js.map,url=tabler-theme.esm.min.js.map\\\" -o dist/js/tabler-theme.esm.min.js\"", + "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": "concurrently \"terser dist/js/tabler.js --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\" \"terser dist/js/tabler.esm.js --module --compress passes=2,ecma=2020 --ecma 2020 --mangle --source-map \\\"content=dist/js/tabler.esm.js.map,filename=dist/js/tabler.esm.min.js.map,url=tabler.esm.min.js.map\\\" -o dist/js/tabler.esm.min.js\"", + "js:min:theme": "concurrently \"terser dist/js/tabler-theme.js --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\" \"terser dist/js/tabler-theme.esm.js --module --compress passes=2,ecma=2020 --ecma 2020 --mangle --source-map \\\"content=dist/js/tabler-theme.esm.js.map,filename=dist/js/tabler-theme.esm.min.js.map,url=tabler-theme.esm.min.js.map\\\" -o dist/js/tabler-theme.esm.min.js\"", + "js:min:map": "concurrently \"terser dist/js/tabler-map.js --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\" \"terser dist/js/tabler-map.esm.js --module --compress passes=2,ecma=2020 --ecma 2020 --mangle --source-map \\\"content=dist/js/tabler-map.esm.js.map,filename=dist/js/tabler-map.esm.min.js.map,url=tabler-map.esm.min.js.map\\\" -o dist/js/tabler-map.esm.min.js\"", + "js:min:vector-map": "concurrently \"terser dist/js/tabler-vector-map.js --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\" \"terser dist/js/tabler-vector-map.esm.js --module --compress passes=2,ecma=2020 --ecma 2020 --mangle --source-map \\\"content=dist/js/tabler-vector-map.esm.js.map,filename=dist/js/tabler-vector-map.esm.min.js.map,url=tabler-vector-map.esm.min.js.map\\\" -o dist/js/tabler-vector-map.esm.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", @@ -84,19 +89,19 @@ "files": [ { "path": "./dist/css/tabler.css", - "maxSize": "88 kB" + "maxSize": "90 kB" }, { "path": "./dist/css/tabler.min.css", - "maxSize": "80 kB" + "maxSize": "84 kB" }, { "path": "./dist/css/tabler.rtl.css", - "maxSize": "88 kB" + "maxSize": "90 kB" }, { "path": "./dist/css/tabler.rtl.min.css", - "maxSize": "80 kB" + "maxSize": "84 kB" }, { "path": "./dist/css/tabler-flags.css", @@ -104,7 +109,7 @@ }, { "path": "./dist/css/tabler-flags.min.css", - "maxSize": "2.1 kB" + "maxSize": "2.5 kB" }, { "path": "./dist/css/tabler-flags.rtl.css", @@ -112,7 +117,7 @@ }, { "path": "./dist/css/tabler-flags.rtl.min.css", - "maxSize": "2.1 kB" + "maxSize": "2.5 kB" }, { "path": "./dist/css/tabler-marketing.css", @@ -148,7 +153,7 @@ }, { "path": "./dist/css/tabler-props.css", - "maxSize": "4.2 kB" + "maxSize": "4.3 kB" }, { "path": "./dist/css/tabler-props.min.css", @@ -156,7 +161,7 @@ }, { "path": "./dist/css/tabler-props.rtl.css", - "maxSize": "4.2 kB" + "maxSize": "4.3 kB" }, { "path": "./dist/css/tabler-props.rtl.min.css", @@ -196,35 +201,51 @@ }, { "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.esm.js", - "maxSize": "58 kB" + "maxSize": "64 kB" }, { "path": "./dist/js/tabler.esm.min.js", - "maxSize": "40 kB" + "maxSize": "42 kB" + }, + { + "path": "./dist/js/tabler-map.js", + "maxSize": "11 kB" + }, + { + "path": "./dist/js/tabler-map.esm.js", + "maxSize": "11 kB" + }, + { + "path": "./dist/js/tabler-map.min.js", + "maxSize": "8 kB" + }, + { + "path": "./dist/js/tabler-map.esm.min.js", + "maxSize": "8 kB" }, { "path": "./dist/js/tabler-theme.js", @@ -241,6 +262,30 @@ { "path": "./dist/js/tabler-theme.esm.min.js", "maxSize": "800 B" + }, + { + "path": "./dist/js/tabler-vector-map.js", + "maxSize": "13 kB" + }, + { + "path": "./dist/js/tabler-vector-map.esm.js", + "maxSize": "13 kB" + }, + { + "path": "./dist/js/tabler-vector-map.min.js", + "maxSize": "9 kB" + }, + { + "path": "./dist/js/tabler-vector-map.esm.min.js", + "maxSize": "9 kB" + }, + { + "path": "./dist/js/maps/world.js", + "maxSize": "28 kB" + }, + { + "path": "./dist/js/maps/world.esm.js", + "maxSize": "28 kB" } ] }, @@ -257,7 +302,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 +310,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", @@ -286,7 +332,7 @@ "star-rating.js": "^4.3.1", "tom-select": "^2.6.2", "typed.js": "^3.0.0", - "typescript": "^7.0.2", + "typescript": "^6.0.3", "vanilla-calendar-pro": "^3.4.0", "vitest": "4.1.11" } diff --git a/core/scss/_core.scss b/core/scss/_core.scss index ca30366c8..fc3def97c 100644 --- a/core/scss/_core.scss +++ b/core/scss/_core.scss @@ -58,6 +58,7 @@ @forward 'ui/datagrid'; @forward 'ui/empty'; @forward 'ui/grid'; +@forward 'ui/hover-gallery'; @forward 'ui/icons'; @forward 'ui/images'; @forward 'ui/forms'; @@ -68,17 +69,22 @@ @forward 'ui/forms/form-custom'; @forward 'ui/forms/form-check'; @forward 'ui/forms/form-otp'; +@forward 'ui/forms/form-number-input'; @forward 'ui/forms/validation'; @forward 'ui/forms/strength'; @forward 'ui/legend'; @forward 'ui/lists'; @forward 'ui/loaders'; @forward 'ui/login'; +@forward 'ui/mockup-browser'; @forward 'ui/modals'; @forward 'ui/nav'; +@forward 'ui/nav-overflow'; @forward 'ui/stars'; @forward 'ui/pagination'; @forward 'ui/popovers'; +@forward 'ui/pricing'; +@forward 'ui/shape'; @forward 'ui/progress'; @forward 'ui/ribbons'; @forward 'ui/markdown'; @@ -90,13 +96,16 @@ @forward 'ui/switch-icon'; @forward 'ui/tables'; @forward 'ui/tags'; +@forward 'ui/text-shimmer'; @forward 'ui/toasts'; @forward 'ui/toolbar'; @forward 'ui/tracking'; @forward 'ui/timeline'; @forward 'ui/type'; +@forward 'ui/kbd'; @forward 'ui/charts'; @forward 'ui/sparkline'; +@forward 'ui/vector-map'; @forward 'ui/offcanvas'; @forward 'ui/chat'; @forward 'ui/signature'; diff --git a/core/scss/_deprecated.scss b/core/scss/_deprecated.scss index 5b58ba37c..4f200eff6 100644 --- a/core/scss/_deprecated.scss +++ b/core/scss/_deprecated.scss @@ -52,6 +52,13 @@ } } + // deprecated(2.0): `--form-select-bg-img` is the old name of `--control-select-bg` + // (from `bootstrap/forms/_form-select.scss`). Still read by `.form-select`, + // so a project that set it keeps its arrow. + .form-select { + --form-select-bg-img: var(--control-select-bg); + } + // `.form-hint` is deprecated, use `.form-text` instead // (from `bootstrap/forms/_form-text.scss`). .form-hint { diff --git a/core/scss/_props.scss b/core/scss/_props.scss index a5f8854c9..f92fd5b9e 100644 --- a/core/scss/_props.scss +++ b/core/scss/_props.scss @@ -3,16 +3,16 @@ :root, :host { - /** Direction multiplier for physical transforms (1 = LTR, -1 = RTL) */ + // Direction multiplier for physical transforms (1 = LTR, -1 = RTL) --dir: 1; - /** Fonts */ + // Fonts --font-monospace: #{$font-family-monospace}; --font-sans-serif: #{$font-family-sans-serif}; --font-serif: #{$font-family-serif}; --font-comic: #{$font-family-comic}; - /** Gray colors */ + // Gray colors --gray-50: #{$gray-50}; --gray-100: #{$gray-100}; --gray-200: #{$gray-200}; @@ -38,10 +38,10 @@ --dark: #{$dark}; --light: #{$light}; - /** Brand colors */ + // Brand colors --brand: #{$primary}; - /** Theme colors */ + // Theme colors @each $name, $color in map.merge($theme-colors, $social-colors) { --#{$name}: #{$color}; --#{$name}-fg: #{if(sass(contrast-ratio($color) > $min-contrast-ratio): var(--light); else: var(--dark))}; @@ -50,20 +50,20 @@ --#{$name}-200: color-mix(in oklab, var(--#{$name}) 20%, transparent); } - /** Gray colors */ + // Gray colors @each $name, $color in $gray-colors { --#{$name}-fg: var(--#{$name}); } - /** Spacers */ + // Spacers @each $name, $value in $spacers { --spacer-#{$name}: #{$value}; } - /** Icons */ + // Icons --icon-size: #{$icon-size}; - /** Font sizes */ + // Font sizes @each $name, $value in $font-weights { --font-weight-#{$name}: #{$value}; } @@ -76,13 +76,13 @@ --line-height-#{$name}: #{$value}; } - /** Shadows */ + // Shadows --shadow-color: #{$box-shadow-color}; @each $name, $value in $box-shadows { --shadow#{if(sass($name): '-#{$name}'; else: '')}: #{$value}; } - /** Border radiuses */ + // Border radiuses --border-radius-scale: 1; @each $name, $value in $border-radiuses { @if $name { @@ -92,7 +92,7 @@ } } - /** Buttons and form controls: one scale, in the names Bootstrap 6 uses */ + // Buttons and form controls: one scale, in the names Bootstrap 6 uses // scss-docs-start btn-input-css-vars --btn-input-fg: var(--body-color); --btn-input-bg: var(--bg-forms); @@ -125,7 +125,15 @@ --btn-input-xl-min-height: calc(var(--btn-input-xl-line-height) + var(--btn-input-xl-padding-y) * 2 + #{$input-btn-border-width} * 2); // scss-docs-end btn-input-css-vars - /** Backdrops */ + // Checks, radios and switches: shared state colors, in the names Bootstrap 6 uses + // scss-docs-start control-state-css-vars + --control-checked-bg: #{$form-check-input-checked-bg-color}; + --control-checked-border-color: #{$form-check-input-checked-border-color}; + --control-disabled-bg: #{$input-disabled-bg}; + --control-disabled-opacity: #{$form-check-input-disabled-opacity}; + // scss-docs-end control-state-css-vars + + // Backdrops --backdrop-opacity: #{$backdrop-opacity}; --backdrop-bg: #{$backdrop-bg}; @each $name, $value in $backdrops { 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; @@ -1279,6 +1327,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; @@ -1357,6 +1443,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 @@ -1447,8 +1535,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(--gray-500) !default; +$list-group-header-bg: var(--fill-tertiary) !default; +$list-group-header-color: var(--secondary-color) !default; // Modals // scss-docs-start modal-variables @@ -1507,7 +1595,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(--gray-500) !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: @@ -1652,7 +1740,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 @@ -1715,6 +1803,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; @@ -1725,6 +1824,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; @@ -1828,7 +1969,7 @@ $toast-border-radius: var(--border-radius) !default; $toast-box-shadow: var(--box-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 @@ -1857,7 +1998,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; @@ -1899,7 +2040,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; @@ -1915,11 +2056,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; @@ -1930,8 +2086,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; @@ -2007,7 +2163,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; @@ -2017,7 +2174,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; @@ -2026,7 +2183,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; @@ -2265,6 +2422,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; @@ -2559,10 +2739,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', @@ -2570,7 +2751,9 @@ $flag-countries: ( 'ao', 'aq', 'ar', + 'arab-league', 'as', + 'asean', 'at', 'au', 'aw', @@ -2589,9 +2772,10 @@ $flag-countries: ( 'bm', 'bn', 'bo', - 'bq-bo', - 'bq-sa', - 'bq-se', + 'bq', + 'bq1', + 'bq2', + 'bq3', 'br', 'bs', 'bt', @@ -2600,17 +2784,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', @@ -2619,17 +2809,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', @@ -2639,11 +2835,11 @@ $flag-countries: ( 'fo', 'fr', 'ga', - 'gb-eng', - 'gb-sct', 'gb', - 'gb-wls', + 'gb-eng', 'gb-nir', + 'gb-sct', + 'gb-wls', 'gd', 'ge', 'gf', @@ -2667,6 +2863,7 @@ $flag-countries: ( 'hr', 'ht', 'hu', + 'ic', 'id', 'ie', 'il', @@ -2681,6 +2878,7 @@ $flag-countries: ( 'jm', 'jo', 'jp', + 'jubaland', 'ke', 'kg', 'kh', @@ -2689,6 +2887,7 @@ $flag-countries: ( 'kn', 'kp', 'kr', + 'kurdistan', 'kw', 'ky', 'kz', @@ -2704,9 +2903,11 @@ $flag-countries: ( 'lv', 'ly', 'ma', + 'maltese-cross', 'mc', 'md', 'me', + 'mercosur', 'mf', 'mg', 'mh', @@ -2727,6 +2928,7 @@ $flag-countries: ( 'my', 'mz', 'na', + 'nato', 'nc', 'ne', 'nf', @@ -2734,16 +2936,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', @@ -2756,6 +2965,8 @@ $flag-countries: ( 'qa', 'rainbow', 're', + 'red-crescent', + 'red-cross', 'ro', 'rs', 'ru', @@ -2765,8 +2976,13 @@ $flag-countries: ( 'sc', 'sd', 'se', + 'sealand', + 'semaphore-signal', 'sg', 'sh', + 'sh-ac', + 'sh-hl', + 'sh-ta', 'si', 'sj', 'sk', @@ -2774,6 +2990,9 @@ $flag-countries: ( 'sm', 'sn', 'so', + 'somaliland', + 'south-american-union', + 'south-ossetia', 'sr', 'ss', 'st', @@ -2781,11 +3000,13 @@ $flag-countries: ( 'sx', 'sy', 'sz', + 'tatarstan', 'tc', 'td', 'tf', 'tg', 'th', + 'tibet', 'tj', 'tk', 'tl', @@ -2793,14 +3014,17 @@ $flag-countries: ( 'tn', 'to', 'tr', + 'transnistria', 'tt', + 'turkic-states', 'tv', 'tw', 'tz', 'ua', 'ug', 'um', - 'unasur', + 'un', + 'unknown', 'us', 'uy', 'uz', @@ -2813,6 +3037,7 @@ $flag-countries: ( 'vu', 'wf', 'ws', + 'xk', 'ye', 'yt', 'za', diff --git a/core/scss/bootstrap/forms/_floating-labels.scss b/core/scss/bootstrap/forms/_floating-labels.scss index 0a1c1a2ac..7c80f3c01 100644 --- a/core/scss/bootstrap/forms/_floating-labels.scss +++ b/core/scss/bootstrap/forms/_floating-labels.scss @@ -1,14 +1,33 @@ @use '../../config' as *; .form-floating { + // scss-docs-start form-floating-css-vars + --form-floating-height: #{$form-floating-height}; + --form-floating-line-height: #{$form-floating-line-height}; + --form-floating-padding-x: #{var-if-default($form-floating-padding-x, $input-btn-padding-x, '--btn-input-padding-x')}; + --form-floating-padding-y: #{$form-floating-padding-y}; + --form-floating-input-padding-t: #{$form-floating-input-padding-t}; + --form-floating-input-padding-b: #{$form-floating-input-padding-b}; + --form-floating-label-height: #{$form-floating-label-height}; + // The textarea label masks the text under it. It is a sibling of the control, + // so it cannot read the control's `--control-bg` and mirrors the scale instead. + --form-floating-label-bg: #{var-if-default($input-bg, var(--bg-forms), '--btn-input-bg')}; + --form-floating-label-disabled-bg: #{$input-disabled-bg}; + --form-floating-label-border-radius: #{var-if-default($input-border-radius, var(--border-radius), '--btn-input-border-radius')}; + --form-floating-label-opacity: #{$form-floating-label-opacity}; + --form-floating-label-transform: #{$form-floating-label-transform}; + --form-floating-label-disabled-color: #{$form-floating-label-disabled-color}; + --form-floating-transition: #{$form-floating-transition}; + // scss-docs-end form-floating-css-vars + position: relative; > .form-control, > .form-control-plaintext, > .form-select { - height: $form-floating-height; - min-height: $form-floating-height; - line-height: $form-floating-line-height; + height: var(--form-floating-height); + min-height: var(--form-floating-height); + line-height: var(--form-floating-line-height); } > label { @@ -18,21 +37,21 @@ z-index: 2; max-width: 100%; height: 100%; // allow textareas - padding: $form-floating-padding-y $form-floating-padding-x; + padding: var(--form-floating-padding-y) var(--form-floating-padding-x); overflow: hidden; - color: color-transparent(var(--body-color), $form-floating-label-opacity); + color: color-transparent(var(--body-color), calc(var(--form-floating-label-opacity) * 100%)); text-align: start; text-overflow: ellipsis; white-space: nowrap; pointer-events: none; border: $input-border-width solid transparent; // Required for aligning label's text with the input as it affects inner box model transform-origin: 0 0; - @include transition($form-floating-transition); + @include transition(var(--form-floating-transition)); } > .form-control, > .form-control-plaintext { - padding: $form-floating-padding-y $form-floating-padding-x; + padding: var(--form-floating-padding-y) var(--form-floating-padding-x); &::placeholder { color: transparent; @@ -40,20 +59,20 @@ &:focus, &:not(:placeholder-shown) { - padding-top: $form-floating-input-padding-t; - padding-bottom: $form-floating-input-padding-b; + padding-top: var(--form-floating-input-padding-t); + padding-bottom: var(--form-floating-input-padding-b); } // Duplicated because `:-webkit-autofill` invalidates other selectors when grouped &:-webkit-autofill { - padding-top: $form-floating-input-padding-t; - padding-bottom: $form-floating-input-padding-b; + padding-top: var(--form-floating-input-padding-t); + padding-bottom: var(--form-floating-input-padding-b); } } > .form-select { - padding-top: $form-floating-input-padding-t; - padding-bottom: $form-floating-input-padding-b; - padding-inline-start: $form-floating-padding-x; + padding-top: var(--form-floating-input-padding-t); + padding-bottom: var(--form-floating-input-padding-b); + padding-inline-start: var(--form-floating-padding-x); } > .form-control:focus, @@ -61,29 +80,29 @@ > .form-control-plaintext, > .form-select { ~ label { - transform: $form-floating-label-transform; + transform: var(--form-floating-label-transform); } } // Duplicated because `:-webkit-autofill` invalidates other selectors when grouped > .form-control:-webkit-autofill { ~ label { - transform: $form-floating-label-transform; + transform: var(--form-floating-label-transform); } } > textarea:focus, > textarea:not(:placeholder-shown) { ~ label::after { position: absolute; - inset: $form-floating-padding-y ($form-floating-padding-x * 0.5); + inset: var(--form-floating-padding-y) calc(var(--form-floating-padding-x) * 0.5); z-index: -1; - height: $form-floating-label-height; + height: var(--form-floating-label-height); content: ''; - background-color: $input-bg; - @include border-radius($input-border-radius); + background-color: var(--form-floating-label-bg); + @include border-radius(var(--form-floating-label-border-radius)); } } > textarea:disabled ~ label::after { - background-color: $input-disabled-bg; + background-color: var(--form-floating-label-disabled-bg); } > .form-control-plaintext { @@ -95,6 +114,6 @@ > :disabled ~ label, > .form-control:disabled ~ label { // Required for `.form-control`s because of specificity - color: $form-floating-label-disabled-color; + color: var(--form-floating-label-disabled-color); } } diff --git a/core/scss/bootstrap/forms/_form-check.scss b/core/scss/bootstrap/forms/_form-check.scss index a527fa9eb..41cef1f09 100644 --- a/core/scss/bootstrap/forms/_form-check.scss +++ b/core/scss/bootstrap/forms/_form-check.scss @@ -5,44 +5,67 @@ @use '../../config' as *; .form-check { + // scss-docs-start form-check-css-vars + --form-check-min-height: #{$form-check-min-height}; + --form-check-padding-start: #{$form-check-padding-start}; + --form-check-margin-bottom: #{$form-check-margin-bottom}; + // scss-docs-end form-check-css-vars + display: block; - min-height: $form-check-min-height; - padding-inline-start: $form-check-padding-start; - margin-bottom: $form-check-margin-bottom; + min-height: var(--form-check-min-height); + padding-inline-start: var(--form-check-padding-start); + margin-bottom: var(--form-check-margin-bottom); .form-check-input { float: inline-start; - margin-inline-start: $form-check-padding-start * -1; + margin-inline-start: calc(var(--form-check-padding-start) * -1); } } .form-check-reverse { - padding-inline-end: $form-check-padding-start; + padding-inline-end: var(--form-check-padding-start); padding-inline-start: 0; text-align: end; .form-check-input { float: inline-end; - margin-inline-end: $form-check-padding-start * -1; + margin-inline-end: calc(var(--form-check-padding-start) * -1); margin-inline-start: 0; } } +// The checked, indeterminate and disabled tokens read the shared state colors +// on `:root` (`--control-checked-bg` and friends) until the Sass variable is +// configured, so one override retunes checks, radios and switches together. .form-check-input { + // scss-docs-start form-check-input-css-vars + --form-check-input-width: #{$form-check-input-width}; --form-check-bg: #{$form-check-input-bg}; + --form-check-border: #{$form-check-input-border}; + --form-check-border-radius: #{$form-check-input-border-radius}; + --form-check-box-shadow: #{$form-check-input-box-shadow}; --form-check-icon-color: #{$form-check-input-checked-color}; + --form-check-focus-border-color: #{$form-check-input-focus-border}; + --form-check-active-filter: #{$form-check-input-active-filter}; + --form-check-checked-bg: #{var-if-default($form-check-input-checked-bg-color, var(--primary), '--control-checked-bg')}; + --form-check-checked-border-color: #{var-if-default($form-check-input-checked-border-color, var(--border-color-translucent), '--control-checked-border-color')}; + --form-check-indeterminate-bg: #{var-if-default($form-check-input-indeterminate-bg-color, var(--primary), '--control-checked-bg')}; + --form-check-indeterminate-border-color: #{var-if-default($form-check-input-indeterminate-border-color, var(--primary), '--form-check-indeterminate-bg')}; + --form-check-disabled-opacity: #{var-if-default($form-check-input-disabled-opacity, 0.5, '--control-disabled-opacity')}; + --form-check-transition: #{$form-check-transition}; + // scss-docs-end form-check-input-css-vars position: relative; flex-shrink: 0; - width: $form-check-input-width; - height: $form-check-input-width; + width: var(--form-check-input-width); + height: var(--form-check-input-width); margin-top: ($line-height-base - $form-check-input-width) * 0.5; // line-height minus check height vertical-align: top; appearance: none; background-color: var(--form-check-bg); - border: $form-check-input-border; + border: var(--form-check-border); print-color-adjust: exact; // Keep themed appearance for print - @include transition($form-check-transition); + @include transition(var(--form-check-transition)); // Start the focus ring's `outline-color` transition from transparent, not `currentcolor`. outline-color: transparent; @@ -62,7 +85,7 @@ &[type='checkbox'] { --form-check-icon: #{escape-svg($form-check-input-checked-bg-image)}; - @include border-radius($form-check-input-border-radius); + @include border-radius(var(--form-check-border-radius)); } &[type='radio'] { @@ -73,17 +96,17 @@ } &:active { - filter: $form-check-input-active-filter; + filter: var(--form-check-active-filter); } &:focus-visible { - border-color: $form-check-input-focus-border; + border-color: var(--form-check-focus-border-color); @include focus-ring(true); } &:checked { - background-color: $form-check-input-checked-bg-color; - border-color: $form-check-input-checked-border-color; + background-color: var(--form-check-checked-bg); + border-color: var(--form-check-checked-border-color); &::before { opacity: 1; @@ -94,8 +117,8 @@ --form-check-icon: #{escape-svg($form-check-input-indeterminate-bg-image)}; --form-check-icon-color: #{$form-check-input-indeterminate-color}; - background-color: $form-check-input-indeterminate-bg-color; - border-color: $form-check-input-indeterminate-border-color; + background-color: var(--form-check-indeterminate-bg); + border-color: var(--form-check-indeterminate-border-color); &::before { opacity: 1; @@ -105,7 +128,7 @@ &:disabled { pointer-events: none; filter: none; - opacity: $form-check-input-disabled-opacity; + opacity: var(--form-check-disabled-opacity); } // Use disabled attribute in addition of :disabled pseudo-class @@ -129,24 +152,38 @@ // .form-switch { - padding-inline-start: $form-switch-padding-start; + // scss-docs-start form-switch-css-vars + --form-switch-width: #{$form-switch-width}; + --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')}; + --form-switch-disabled-color: #{$form-switch-disabled-color}; + // scss-docs-end form-switch-css-vars + + padding-inline-start: var(--form-switch-padding-start); .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(#{$form-switch-height} * 0.75); - --form-switch-knob-offset: calc(#{$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: $form-switch-width; - margin-inline-start: $form-switch-padding-start * -1; - @include border-radius($form-switch-border-radius, 0); + width: var(--form-switch-width); + margin-inline-start: calc(var(--form-switch-padding-start) * -1); + @include border-radius(var(--form-switch-border-radius), 0); @include transition($form-switch-transition); // 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; @@ -158,25 +195,32 @@ @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: #{$form-switch-focus-color}; + --form-switch-color: var(--form-switch-focus-color); } &:checked { - --form-switch-color: #{$form-switch-checked-color}; + --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)); } } } &.form-check-reverse { - padding-inline-end: $form-switch-padding-start; + padding-inline-end: var(--form-switch-padding-start); padding-inline-start: 0; .form-check-input { - margin-inline-end: $form-switch-padding-start * -1; + margin-inline-end: calc(var(--form-switch-padding-start) * -1); margin-inline-start: 0; } } diff --git a/core/scss/bootstrap/forms/_form-control.scss b/core/scss/bootstrap/forms/_form-control.scss index cf92336d8..584a60072 100644 --- a/core/scss/bootstrap/forms/_form-control.scss +++ b/core/scss/bootstrap/forms/_form-control.scss @@ -6,25 +6,57 @@ @use '../../config' as *; +// The control tokens, in the names Bootstrap 6 uses. One block serves every +// text-like control: `.form-select` and `.form-control-plaintext` stand alone +// (no `.form-control`), so they need the same declarations. A token seeded from +// a size variable reads the `:root` scale until the Sass variable is configured. +.form-select, +.form-control-plaintext, +.form-control { + // scss-docs-start control-css-vars + --control-min-height: calc(var(--control-line-height) + var(--control-padding-y) * 2 + var(--control-border-width) * 2); + --control-padding-y: #{var-if-default($input-padding-y, $input-btn-padding-y, '--btn-input-padding-y')}; + --control-padding-x: #{var-if-default($input-padding-x, $input-btn-padding-x, '--btn-input-padding-x')}; + @include rfs(var-if-default($input-font-size, $input-btn-font-size, '--btn-input-font-size'), --control-font-size); + --control-line-height: #{var-if-default($input-line-height, $input-btn-line-height, '--btn-input-line-height')}; + --control-fg: #{var-if-default($input-color, var(--body-color), '--btn-input-fg')}; + --control-bg: #{var-if-default($input-bg, var(--bg-forms), '--btn-input-bg')}; + --control-border-width: #{$input-border-width}; + --control-border-color: #{$input-border-color}; + --control-border-radius: #{var-if-default($input-border-radius, var(--border-radius), '--btn-input-border-radius')}; + --control-box-shadow: #{$input-box-shadow}; + --control-focus-fg: #{var-if-default($input-focus-color, $input-color, '--control-fg')}; + --control-focus-bg: #{var-if-default($input-focus-bg, $input-bg, '--control-bg')}; + --control-focus-border-color: #{$input-focus-border-color}; + --control-placeholder-color: #{$input-placeholder-color}; + --control-disabled-color: #{if(sass($input-disabled-color): $input-disabled-color; else: $form-secondary-color)}; + --control-disabled-bg: #{$input-disabled-bg}; + --control-disabled-border-color: #{if(sass($input-disabled-border-color): $input-disabled-border-color; else: var(--control-border-color))}; + --control-action-bg: #{$form-file-button-bg}; + --control-action-hover-bg: #{$form-file-button-hover-bg}; + --control-transition: #{$input-transition}; + // scss-docs-end control-css-vars +} + .form-control { display: block; width: 100%; - padding: $input-padding-y $input-padding-x; + padding: var(--control-padding-y) var(--control-padding-x); font-family: $input-font-family; - @include font-size($input-font-size); + @include font-size(var(--control-font-size)); font-weight: $input-font-weight; - line-height: $input-line-height; - color: $input-color; + line-height: var(--control-line-height); + color: var(--control-fg); appearance: none; // Fix appearance for date inputs in Safari - background-color: $input-bg; + background-color: var(--control-bg); background-clip: padding-box; - border: $input-border-width solid $input-border-color; + border: var(--control-border-width) solid var(--control-border-color); // Note: This has no effect on `s in CSS. - @include border-radius($input-border-radius, 0); + @include border-radius(var(--control-border-radius), 0); - @include box-shadow($input-box-shadow); - @include transition($input-transition); + @include box-shadow(var(--control-box-shadow)); + @include transition(var(--control-transition)); // Start the focus ring's `outline-color` transition from transparent, not `currentcolor`. outline-color: transparent; @@ -38,9 +70,9 @@ // Customize the `:focus` state to imitate native WebKit styles. &:focus { - color: $input-focus-color; - background-color: $input-focus-bg; - border-color: $input-focus-border-color; + color: var(--control-focus-fg); + background-color: var(--control-focus-bg); + border-color: var(--control-focus-border-color); --focus-ring-offset: -1px; @include focus-ring(true); } @@ -74,7 +106,7 @@ // Placeholder &::placeholder { - color: $input-placeholder-color; + color: var(--control-placeholder-color); // Override Firefox's unusual default opacity; see https://github.com/twbs/bootstrap/pull/11526. opacity: 1; } @@ -85,31 +117,31 @@ // disabled if the fieldset is disabled. Due to implementation difficulty, we // don't honor that edge case; we style them as disabled anyway. &:disabled { - color: $input-disabled-color; - background-color: $input-disabled-bg; - border-color: $input-disabled-border-color; + color: var(--control-disabled-color); + background-color: var(--control-disabled-bg); + border-color: var(--control-disabled-border-color); // iOS fix for unreadable disabled content; see https://github.com/twbs/bootstrap/issues/11655. opacity: 1; } // File input buttons theming &::file-selector-button { - padding: $input-padding-y $input-padding-x; - margin: (-$input-padding-y) (-$input-padding-x); - margin-inline-end: $input-padding-x; + padding: var(--control-padding-y) var(--control-padding-x); + margin: calc(var(--control-padding-y) * -1) calc(var(--control-padding-x) * -1); + margin-inline-end: var(--control-padding-x); color: $form-file-button-color; - @include gradient-bg($form-file-button-bg); + @include gradient-bg(var(--control-action-bg)); pointer-events: none; border-color: inherit; border-style: solid; border-width: 0; - border-inline-end-width: $input-border-width; + border-inline-end-width: var(--control-border-width); border-radius: 0; // stylelint-disable-line property-disallowed-list @include transition($btn-transition); } &:hover:not(:disabled):not([readonly])::file-selector-button { - background-color: $form-file-button-hover-bg; + background-color: var(--control-action-hover-bg); } } @@ -121,13 +153,13 @@ .form-control-plaintext { display: block; width: 100%; - padding: $input-padding-y 0; + padding: var(--control-padding-y) 0; margin-bottom: 0; // match inputs if this class comes on inputs with default margins - line-height: $input-line-height; - color: $input-plaintext-color; + line-height: var(--control-line-height); + color: #{var-if-default($input-plaintext-color, $input-color, '--control-fg')}; background-color: transparent; border: solid transparent; - border-width: $input-border-width 0; + border-width: var(--control-border-width) 0; &:focus { outline: 0; @@ -143,36 +175,29 @@ // Form control sizing // // Build on `.form-control` with modifier classes to decrease or increase the -// height and font-size of form controls. +// height and font-size of form controls. A size only moves the tokens; the +// height, the file button and the color swatch follow. // // Repeated in `_input_group.scss` to avoid Sass extend issues. .form-control-sm { - line-height: $input-line-height-sm; - min-height: $input-height-sm; - padding: $input-padding-y-sm $input-padding-x-sm; - @include font-size($input-font-size-sm); - @include border-radius($input-border-radius-sm); - - &::file-selector-button { - padding: $input-padding-y-sm $input-padding-x-sm; - margin: (-$input-padding-y-sm) (-$input-padding-x-sm); - margin-inline-end: $input-padding-x-sm; - } + min-height: var(--control-min-height); + @include font-size(var(--control-font-size)); + --control-padding-y: #{var-if-default($input-padding-y-sm, $input-btn-padding-y-sm, '--btn-input-sm-padding-y')}; + --control-padding-x: #{var-if-default($input-padding-x-sm, $input-btn-padding-x-sm, '--btn-input-sm-padding-x')}; + @include rfs(var-if-default($input-font-size-sm, $input-btn-font-size-sm, '--btn-input-sm-font-size'), --control-font-size); + --control-line-height: #{var-if-default($input-line-height-sm, $input-btn-line-height-sm, '--btn-input-sm-line-height')}; + --control-border-radius: #{var-if-default($input-border-radius-sm, var(--border-radius-sm), '--btn-input-sm-border-radius')}; } .form-control-lg { - line-height: $input-line-height-lg; - min-height: $input-height-lg; - padding: $input-padding-y-lg $input-padding-x-lg; - @include font-size($input-font-size-lg); - @include border-radius($input-border-radius-lg); - - &::file-selector-button { - padding: $input-padding-y-lg $input-padding-x-lg; - margin: (-$input-padding-y-lg) (-$input-padding-x-lg); - margin-inline-end: $input-padding-x-lg; - } + min-height: var(--control-min-height); + @include font-size(var(--control-font-size)); + --control-padding-y: #{var-if-default($input-padding-y-lg, $input-btn-padding-y-lg, '--btn-input-lg-padding-y')}; + --control-padding-x: #{var-if-default($input-padding-x-lg, $input-btn-padding-x-lg, '--btn-input-lg-padding-x')}; + @include rfs(var-if-default($input-font-size-lg, $input-btn-font-size-lg, '--btn-input-lg-font-size'), --control-font-size); + --control-line-height: #{var-if-default($input-line-height-lg, $input-btn-line-height-lg, '--btn-input-lg-line-height')}; + --control-border-radius: #{var-if-default($input-border-radius-lg, var(--border-radius-lg), '--btn-input-lg-border-radius')}; } // Make sure textareas don't shrink too much when resized @@ -180,15 +205,7 @@ // stylelint-disable selector-no-qualifying-type textarea { &.form-control { - min-height: $input-height; - } - - &.form-control-sm { - min-height: $input-height-sm; - } - - &.form-control-lg { - min-height: $input-height-lg; + min-height: var(--control-min-height); } } @@ -196,8 +213,8 @@ textarea { .form-control-color { width: $form-color-width; - height: $input-height; - padding: $input-padding-y; + height: var(--control-min-height); + padding: var(--control-padding-y); &:not(:disabled):not([readonly]) { cursor: pointer; @@ -205,19 +222,11 @@ textarea { &::-moz-color-swatch { border: 0 !important; // stylelint-disable-line declaration-no-important - @include border-radius($input-border-radius); + @include border-radius(var(--control-border-radius)); } &::-webkit-color-swatch { border: 0 !important; // stylelint-disable-line declaration-no-important - @include border-radius($input-border-radius); - } - - &.form-control-sm { - height: $input-height-sm; - } - - &.form-control-lg { - height: $input-height-lg; + @include border-radius(var(--control-border-radius)); } } diff --git a/core/scss/bootstrap/forms/_form-range.scss b/core/scss/bootstrap/forms/_form-range.scss index 86477ede0..44d911c2e 100644 --- a/core/scss/bootstrap/forms/_form-range.scss +++ b/core/scss/bootstrap/forms/_form-range.scss @@ -7,8 +7,26 @@ @use '../../config' as *; .form-range { + // scss-docs-start range-css-vars + --range-track-width: #{$form-range-track-width}; + --range-track-height: #{$form-range-track-height}; + --range-track-cursor: #{$form-range-track-cursor}; + --range-track-bg: #{$form-range-track-bg}; + --range-track-border-radius: #{$form-range-track-border-radius}; + --range-track-box-shadow: #{$form-range-track-box-shadow}; + --range-thumb-width: #{$form-range-thumb-width}; + --range-thumb-height: #{$form-range-thumb-height}; + --range-thumb-bg: #{$form-range-thumb-bg}; + --range-thumb-border: #{$form-range-thumb-border}; + --range-thumb-border-radius: #{$form-range-thumb-border-radius}; + --range-thumb-box-shadow: #{$form-range-thumb-box-shadow}; + --range-thumb-active-bg: #{$form-range-thumb-active-bg}; + --range-thumb-disabled-bg: #{$form-range-thumb-disabled-bg}; + --range-thumb-transition: #{$form-range-thumb-transition}; + // scss-docs-end range-css-vars + width: 100%; - height: add($form-range-thumb-height, $form-range-thumb-focus-box-shadow-width * 2); + height: calc(var(--range-thumb-height) + #{$form-range-thumb-focus-box-shadow-width * 2}); padding: 0; // Need to reset padding appearance: none; background-color: transparent; @@ -31,69 +49,69 @@ } &::-webkit-slider-thumb { - width: $form-range-thumb-width; - height: $form-range-thumb-height; - margin-top: ($form-range-track-height - $form-range-thumb-height) * 0.5; // Webkit specific + width: var(--range-thumb-width); + height: var(--range-thumb-height); + margin-top: calc((var(--range-track-height) - var(--range-thumb-height)) / 2); // Webkit specific appearance: none; - @include gradient-bg($form-range-thumb-bg); - border: $form-range-thumb-border; - @include border-radius($form-range-thumb-border-radius); - @include box-shadow($form-range-thumb-box-shadow); - @include transition($form-range-thumb-transition); + @include gradient-bg(var(--range-thumb-bg)); + border: var(--range-thumb-border); + @include border-radius(var(--range-thumb-border-radius)); + @include box-shadow(var(--range-thumb-box-shadow)); + @include transition(var(--range-thumb-transition)); outline-color: transparent; &:active { - @include gradient-bg($form-range-thumb-active-bg); + @include gradient-bg(var(--range-thumb-active-bg)); } } &::-webkit-slider-runnable-track { - width: $form-range-track-width; - height: $form-range-track-height; + width: var(--range-track-width); + height: var(--range-track-height); color: transparent; // Why? - cursor: $form-range-track-cursor; - background-color: $form-range-track-bg; + cursor: var(--range-track-cursor); + background-color: var(--range-track-bg); border-color: transparent; - @include border-radius($form-range-track-border-radius); - @include box-shadow($form-range-track-box-shadow); + @include border-radius(var(--range-track-border-radius)); + @include box-shadow(var(--range-track-box-shadow)); } &::-moz-range-thumb { - width: $form-range-thumb-width; - height: $form-range-thumb-height; + width: var(--range-thumb-width); + height: var(--range-thumb-height); appearance: none; - @include gradient-bg($form-range-thumb-bg); - border: $form-range-thumb-border; - @include border-radius($form-range-thumb-border-radius); - @include box-shadow($form-range-thumb-box-shadow); - @include transition($form-range-thumb-transition); + @include gradient-bg(var(--range-thumb-bg)); + border: var(--range-thumb-border); + @include border-radius(var(--range-thumb-border-radius)); + @include box-shadow(var(--range-thumb-box-shadow)); + @include transition(var(--range-thumb-transition)); outline-color: transparent; &:active { - @include gradient-bg($form-range-thumb-active-bg); + @include gradient-bg(var(--range-thumb-active-bg)); } } &::-moz-range-track { - width: $form-range-track-width; - height: $form-range-track-height; + width: var(--range-track-width); + height: var(--range-track-height); color: transparent; - cursor: $form-range-track-cursor; - background-color: $form-range-track-bg; + cursor: var(--range-track-cursor); + background-color: var(--range-track-bg); border-color: transparent; // Firefox specific? - @include border-radius($form-range-track-border-radius); - @include box-shadow($form-range-track-box-shadow); + @include border-radius(var(--range-track-border-radius)); + @include box-shadow(var(--range-track-box-shadow)); } &:disabled { pointer-events: none; &::-webkit-slider-thumb { - background-color: $form-range-thumb-disabled-bg; + background-color: var(--range-thumb-disabled-bg); } &::-moz-range-thumb { - background-color: $form-range-thumb-disabled-bg; + background-color: var(--range-thumb-disabled-bg); } } } diff --git a/core/scss/bootstrap/forms/_form-select.scss b/core/scss/bootstrap/forms/_form-select.scss index cf57dbf43..c4a7929b0 100644 --- a/core/scss/bootstrap/forms/_form-select.scss +++ b/core/scss/bootstrap/forms/_form-select.scss @@ -5,75 +5,111 @@ @use '../../config' as *; +// The `--control-*` tokens come from the shared block in `_form-control.scss`. +// A `$form-select-*` variable defaults to its `$input-*` twin; only a value a +// project changed on its own has to be written again here. +@mixin -select-token($token, $value, $default) { + @if $value != $default { + #{$token}: #{$value}; + } +} + .form-select { - --form-select-bg-img: #{escape-svg($form-select-indicator)}; + // scss-docs-start form-select-css-vars + --control-select-bg: #{escape-svg($form-select-indicator)}; + --control-select-bg-dark: #{escape-svg($form-select-indicator-dark)}; + --control-select-bg-position: #{$form-select-bg-position}; + --control-select-bg-size: #{$form-select-bg-size}; + // Room for the arrow. Reads the root scale, not `--control-padding-x`, so the + // size modifiers keep the same 3rem they always had. + --control-select-indicator-padding: #{if(sass($form-select-indicator-padding == $form-select-padding-x * 3 and $form-select-padding-x == $input-btn-padding-x): calc(var(--btn-input-padding-x) * 3); else: $form-select-indicator-padding)}; + // scss-docs-end form-select-css-vars + @include -select-token(--control-padding-y, $form-select-padding-y, $input-padding-y); + @include -select-token(--control-padding-x, $form-select-padding-x, $input-padding-x); + @include -select-token(--control-font-size, $form-select-font-size, $input-font-size); + @include -select-token(--control-line-height, $form-select-line-height, $input-line-height); + @include -select-token(--control-fg, $form-select-color, $input-color); + @include -select-token(--control-bg, $form-select-bg, $input-bg); + @include -select-token(--control-border-width, $form-select-border-width, $input-border-width); + @include -select-token(--control-border-color, $form-select-border-color, $input-border-color); + @include -select-token(--control-border-radius, $form-select-border-radius, $input-border-radius); + @include -select-token(--control-box-shadow, $form-select-box-shadow, $input-box-shadow); + @include -select-token(--control-focus-border-color, $form-select-focus-border-color, $input-focus-border-color); + @include -select-token(--control-disabled-color, $form-select-disabled-color, $input-disabled-color); + @include -select-token(--control-disabled-bg, $form-select-disabled-bg, $input-disabled-bg); + @include -select-token(--control-disabled-border-color, $form-select-disabled-border-color, $input-disabled-border-color); + @include -select-token(--control-transition, $form-select-transition, $input-transition); display: block; width: 100%; - padding: $form-select-padding-y $form-select-indicator-padding $form-select-padding-y $form-select-padding-x; + padding: var(--control-padding-y) var(--control-padding-x); + padding-inline-end: var(--control-select-indicator-padding); font-family: $form-select-font-family; - @include font-size($form-select-font-size); + @include font-size(var(--control-font-size)); font-weight: $form-select-font-weight; - line-height: $form-select-line-height; - color: $form-select-color; + line-height: var(--control-line-height); + color: var(--control-fg); appearance: none; - background-color: $form-select-bg; - background-image: var(--form-select-bg-img), var(--form-select-bg-icon, none); + background-color: var(--control-bg); + // deprecated(2.0): `--form-select-bg-img` is the old name of `--control-select-bg` + background-image: var(--form-select-bg-img, var(--control-select-bg)), var(--form-select-bg-icon, none); background-repeat: no-repeat; - background-position: $form-select-bg-position; - background-size: $form-select-bg-size; - border: $form-select-border-width solid $form-select-border-color; - @include border-radius($form-select-border-radius, 0); - @include box-shadow($form-select-box-shadow); - @include transition($form-select-transition); + background-position: var(--control-select-bg-position); + background-size: var(--control-select-bg-size); + border: var(--control-border-width) solid var(--control-border-color); + @include border-radius(var(--control-border-radius), 0); + @include box-shadow(var(--control-box-shadow)); + @include transition(var(--control-transition)); // Start the focus ring's `outline-color` transition from transparent, not `currentcolor`. outline-color: transparent; &:focus { - border-color: $form-select-focus-border-color; + border-color: var(--control-focus-border-color); --focus-ring-offset: -1px; @include focus-ring(true); } &[multiple], &[size]:not([size='1']) { - padding-inline-end: $form-select-padding-x; + padding-inline-end: var(--control-padding-x); background-image: none; } &:disabled { - color: $form-select-disabled-color; - background-color: $form-select-disabled-bg; - border-color: $form-select-disabled-border-color; + color: var(--control-disabled-color); + background-color: var(--control-disabled-bg); + border-color: var(--control-disabled-border-color); } // Remove outline from select box in FF &:-moz-focusring { color: transparent; - text-shadow: 0 0 0 $form-select-color; + text-shadow: 0 0 0 var(--control-fg); } } +// A size moves the same tokens as `.form-control-sm/-lg`, line height +// included, so a select and an input of one size are equally tall. .form-select-sm { - padding-top: $form-select-padding-y-sm; - padding-bottom: $form-select-padding-y-sm; - padding-inline-start: $form-select-padding-x-sm; - @include font-size($form-select-font-size-sm); - @include border-radius($form-select-border-radius-sm); + --control-padding-y: #{var-if-default($form-select-padding-y-sm, $input-btn-padding-y-sm, '--btn-input-sm-padding-y')}; + --control-padding-x: #{var-if-default($form-select-padding-x-sm, $input-btn-padding-x-sm, '--btn-input-sm-padding-x')}; + @include rfs(var-if-default($form-select-font-size-sm, $input-btn-font-size-sm, '--btn-input-sm-font-size'), --control-font-size); + --control-line-height: #{var-if-default($input-line-height-sm, $input-btn-line-height-sm, '--btn-input-sm-line-height')}; + --control-border-radius: #{var-if-default($form-select-border-radius-sm, var(--border-radius-sm), '--btn-input-sm-border-radius')}; } .form-select-lg { - padding-top: $form-select-padding-y-lg; - padding-bottom: $form-select-padding-y-lg; - padding-inline-start: $form-select-padding-x-lg; - @include font-size($form-select-font-size-lg); - @include border-radius($form-select-border-radius-lg); + --control-padding-y: #{var-if-default($form-select-padding-y-lg, $input-btn-padding-y-lg, '--btn-input-lg-padding-y')}; + --control-padding-x: #{var-if-default($form-select-padding-x-lg, $input-btn-padding-x-lg, '--btn-input-lg-padding-x')}; + @include rfs(var-if-default($form-select-font-size-lg, $input-btn-font-size-lg, '--btn-input-lg-font-size'), --control-font-size); + --control-line-height: #{var-if-default($input-line-height-lg, $input-btn-line-height-lg, '--btn-input-lg-line-height')}; + --control-border-radius: #{var-if-default($form-select-border-radius-lg, var(--border-radius-lg), '--btn-input-lg-border-radius')}; } @if $enable-dark-mode { @include color-mode(dark) { .form-select { - --form-select-bg-img: #{escape-svg($form-select-indicator-dark)}; + --control-select-bg: var(--control-select-bg-dark); } } } diff --git a/core/scss/bootstrap/forms/_input-group.scss b/core/scss/bootstrap/forms/_input-group.scss index 9b2577c43..a16fcb6aa 100644 --- a/core/scss/bootstrap/forms/_input-group.scss +++ b/core/scss/bootstrap/forms/_input-group.scss @@ -7,7 +7,21 @@ @use '../../config' as *; +// Addon tokens, in the names Bootstrap 6 uses. The addon sits next to the +// control, not inside it, so it reads the `:root` scale rather than the +// control's own `--control-*` tokens. .input-group { + // scss-docs-start input-group-css-vars + --input-group-addon-padding-y: #{var-if-default($input-group-addon-padding-y, $input-btn-padding-y, '--btn-input-padding-y')}; + --input-group-addon-padding-x: #{var-if-default($input-group-addon-padding-x, $input-btn-padding-x, '--btn-input-padding-x')}; + @include rfs(var-if-default($input-font-size, $input-btn-font-size, '--btn-input-font-size'), --input-group-addon-font-size); + --input-group-addon-line-height: #{var-if-default($input-line-height, $input-btn-line-height, '--btn-input-line-height')}; + --input-group-addon-color: #{$input-group-addon-color}; + --input-group-addon-bg: #{$input-group-addon-bg}; + --input-group-addon-border-color: #{$input-group-addon-border-color}; + --input-group-addon-border-radius: #{var-if-default($input-border-radius, var(--border-radius), '--btn-input-border-radius')}; + // scss-docs-end input-group-css-vars + position: relative; display: flex; flex-wrap: wrap; // For form validation feedback @@ -51,48 +65,88 @@ .input-group-text { display: flex; align-items: center; - padding: $input-group-addon-padding-y $input-group-addon-padding-x; - @include font-size($input-font-size); // Match inputs + padding: var(--input-group-addon-padding-y) var(--input-group-addon-padding-x); + @include font-size(var(--input-group-addon-font-size)); // Match inputs font-weight: $input-group-addon-font-weight; - line-height: $input-line-height; - color: $input-group-addon-color; + line-height: var(--input-group-addon-line-height); + color: var(--input-group-addon-color); text-align: center; white-space: nowrap; - background-color: $input-group-addon-bg; - border: $input-border-width solid $input-group-addon-border-color; - @include border-radius($input-border-radius); + background-color: var(--input-group-addon-bg); + border: $input-border-width solid var(--input-group-addon-border-color); + @include border-radius(var(--input-group-addon-border-radius)); } // Sizing // -// Remix the default form control sizing classes into new ones for easier -// manipulation. +// A size moves the addon and control tokens; the button keeps its own rules +// until `.btn` reads the scale too. -.input-group-lg > .form-control, -.input-group-lg > .form-select, -.input-group-lg > .input-group-text, -.input-group-lg > .btn { - line-height: $input-line-height-lg; - min-height: $input-height-lg; - padding: $input-padding-y-lg $input-padding-x-lg; - @include font-size($input-font-size-lg); - @include border-radius($input-border-radius-lg); +@mixin -input-group-size($size) { + --input-group-addon-padding-y: #{var-if-default(map.get($-input-group-sizes, $size, 'padding-y'), map.get($-input-group-sizes, $size, 'scale-padding-y'), '--btn-input-#{$size}-padding-y')}; + --input-group-addon-padding-x: #{var-if-default(map.get($-input-group-sizes, $size, 'padding-x'), map.get($-input-group-sizes, $size, 'scale-padding-x'), '--btn-input-#{$size}-padding-x')}; + @include rfs(var-if-default(map.get($-input-group-sizes, $size, 'font-size'), map.get($-input-group-sizes, $size, 'scale-font-size'), '--btn-input-#{$size}-font-size'), --input-group-addon-font-size); + --input-group-addon-line-height: #{var-if-default(map.get($-input-group-sizes, $size, 'line-height'), map.get($-input-group-sizes, $size, 'scale-line-height'), '--btn-input-#{$size}-line-height')}; + --input-group-addon-border-radius: #{var-if-default(map.get($-input-group-sizes, $size, 'border-radius'), map.get($-input-group-sizes, $size, 'scale-border-radius'), '--btn-input-#{$size}-border-radius')}; + + > .form-control, + > .form-select { + --control-padding-y: var(--input-group-addon-padding-y); + --control-padding-x: var(--input-group-addon-padding-x); + --control-font-size: var(--input-group-addon-font-size); + --control-line-height: var(--input-group-addon-line-height); + --control-border-radius: var(--input-group-addon-border-radius); + min-height: var(--control-min-height); + } + + > .form-select { + padding-inline-end: calc(#{var-if-default($form-select-padding-x, $input-btn-padding-x, '--btn-input-padding-x')} + var(--control-select-indicator-padding)); + } + + > .btn { + line-height: map.get($-input-group-sizes, $size, 'line-height'); + min-height: map.get($-input-group-sizes, $size, 'height'); + padding: map.get($-input-group-sizes, $size, 'padding-y') map.get($-input-group-sizes, $size, 'padding-x'); + @include font-size(map.get($-input-group-sizes, $size, 'font-size')); + @include border-radius(map.get($-input-group-sizes, $size, 'border-radius')); + } } -.input-group-sm > .form-control, -.input-group-sm > .form-select, -.input-group-sm > .input-group-text, -.input-group-sm > .btn { - line-height: $input-line-height-sm; - min-height: $input-height-sm; - padding: $input-padding-y-sm $input-padding-x-sm; - @include font-size($input-font-size-sm); - @include border-radius($input-border-radius-sm); +$-input-group-sizes: ( + 'sm': ( + 'padding-y': $input-padding-y-sm, + 'padding-x': $input-padding-x-sm, + 'font-size': $input-font-size-sm, + 'line-height': $input-line-height-sm, + 'border-radius': $input-border-radius-sm, + 'height': $input-height-sm, + 'scale-padding-y': $input-btn-padding-y-sm, + 'scale-padding-x': $input-btn-padding-x-sm, + 'scale-font-size': $input-btn-font-size-sm, + 'scale-line-height': $input-btn-line-height-sm, + 'scale-border-radius': var(--border-radius-sm), + ), + 'lg': ( + 'padding-y': $input-padding-y-lg, + 'padding-x': $input-padding-x-lg, + 'font-size': $input-font-size-lg, + 'line-height': $input-line-height-lg, + 'border-radius': $input-border-radius-lg, + 'height': $input-height-lg, + 'scale-padding-y': $input-btn-padding-y-lg, + 'scale-padding-x': $input-btn-padding-x-lg, + 'scale-font-size': $input-btn-font-size-lg, + 'scale-line-height': $input-btn-line-height-lg, + 'scale-border-radius': var(--border-radius-lg), + ), +); + +.input-group-lg { + @include -input-group-size('lg'); } -.input-group-lg > .form-select, -.input-group-sm > .form-select { - padding-inline-end: $form-select-padding-x + $form-select-indicator-padding; +.input-group-sm { + @include -input-group-size('sm'); } // Rounded corners diff --git a/core/scss/bootstrap/forms/_labels.scss b/core/scss/bootstrap/forms/_labels.scss index 763fa7d20..5b49a38bb 100644 --- a/core/scss/bootstrap/forms/_labels.scss +++ b/core/scss/bootstrap/forms/_labels.scss @@ -15,24 +15,24 @@ // For use with horizontal and inline forms, when you need the label (or legend) // text to align with the form controls. .col-form-label { - padding-top: add($input-padding-y, $input-border-width); - padding-bottom: add($input-padding-y, $input-border-width); + padding-top: calc(#{var-if-default($input-padding-y, $input-btn-padding-y, '--btn-input-padding-y')} + #{$input-border-width}); + padding-bottom: calc(#{var-if-default($input-padding-y, $input-btn-padding-y, '--btn-input-padding-y')} + #{$input-border-width}); margin-bottom: 0; // Override the `` default @include font-size(inherit); // Override the `` default font-style: $form-label-font-style; font-weight: $form-label-font-weight; - line-height: $input-line-height; + line-height: #{var-if-default($input-line-height, $input-btn-line-height, '--btn-input-line-height')}; color: $form-label-color; } .col-form-label-lg { - padding-top: add($input-padding-y-lg, $input-border-width); - padding-bottom: add($input-padding-y-lg, $input-border-width); - @include font-size($input-font-size-lg); + padding-top: calc(#{var-if-default($input-padding-y-lg, $input-btn-padding-y-lg, '--btn-input-lg-padding-y')} + #{$input-border-width}); + padding-bottom: calc(#{var-if-default($input-padding-y-lg, $input-btn-padding-y-lg, '--btn-input-lg-padding-y')} + #{$input-border-width}); + @include font-size(var-if-default($input-font-size-lg, $input-btn-font-size-lg, '--btn-input-lg-font-size')); } .col-form-label-sm { - padding-top: add($input-padding-y-sm, $input-border-width); - padding-bottom: add($input-padding-y-sm, $input-border-width); - @include font-size($input-font-size-sm); + padding-top: calc(#{var-if-default($input-padding-y-sm, $input-btn-padding-y-sm, '--btn-input-sm-padding-y')} + #{$input-border-width}); + padding-bottom: calc(#{var-if-default($input-padding-y-sm, $input-btn-padding-y-sm, '--btn-input-sm-padding-y')} + #{$input-border-width}); + @include font-size(var-if-default($input-font-size-sm, $input-btn-font-size-sm, '--btn-input-sm-font-size')); } diff --git a/core/scss/layout/_navbar.scss b/core/scss/layout/_navbar.scss index 46ae231a4..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; @@ -299,9 +301,7 @@ } } -/** -Navbar - */ +// Navbar .navbar { --navbar-bg: var(--bg-surface); --navbar-border-width: #{$navbar-border-width}; @@ -455,8 +455,15 @@ Navbar } } + // ~ .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)); } @@ -465,6 +472,7 @@ Navbar &.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)); @@ -487,9 +495,7 @@ Navbar } } -/** -Navbar brand - */ +// Navbar brand .navbar-brand { display: inline-flex; gap: $spacer-2; @@ -509,9 +515,7 @@ Navbar brand opacity: 0.5; } -/** -Navbar toggler - */ +// Navbar toggler .navbar-toggler { position: relative; display: flex; @@ -572,17 +576,13 @@ Navbar toggler } } -/** -Navbar transparent - */ +// Navbar transparent .navbar-transparent { --navbar-border-color: transparent !important; background: transparent !important; } -/** -Navbar nav - */ +// Navbar nav .navbar-nav { --nav-link-hover-bg: #{$navbar-nav-link-hover-bg}; --nav-link-icon-color: #{$nav-link-icon-color}; @@ -598,9 +598,7 @@ Navbar nav } } -/** -Navbar side - */ +// Navbar side // The group at the end of a navbar: a row on the end side of a horizontal // navbar. In a vertical navbar the same class pins the group to the bottom // of the menu — see navbar-vertical-nav(). @@ -612,9 +610,7 @@ Navbar side margin: 0; } -/** -Navbar sticky - */ +// Navbar sticky // Runtime route only (data-bs-navbar="sticky" on ); static markup wraps // the navbar in its own .sticky-top. Just the top bar sticks — on the two-row // navbar the menu row scrolls away, so the sticky strip stays one row high. @@ -624,9 +620,7 @@ html[data-bs-navbar='sticky'] .page > .navbar:not(.navbar-vertical) { z-index: $zindex-sticky; } -/** -Navbar primary - */ +// Navbar primary // Runtime route only (data-bs-navbar-theme="primary" on ): the primary // color as the navbar surface. The text palette comes from the dark color // mode, which _extends.scss applies to the same selector; static markup gets @@ -642,9 +636,7 @@ html[data-bs-navbar-theme='primary'] .page > [class*='navbar-expand'] .navbar { --navbar-border-color: transparent; } -/** -Navbar vertical - */ +// Navbar vertical @if $enable-navbar-vertical { // Folded sidebar: every geometry consumer (aside width, page paddings, // page-wrapper/navbar margins) reads --sidebar-width, so flipping this one @@ -673,7 +665,8 @@ Navbar vertical // 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); } } @@ -681,23 +674,28 @@ Navbar vertical // 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); } @@ -878,9 +876,7 @@ Navbar vertical } } -/** -Navbar floating - */ +// Navbar floating // The navbar keeps a gap from the edges around it, like .offcanvas-floating, // with an outline on every side instead of the single border line. // diff --git a/core/scss/layout/_root.scss b/core/scss/layout/_root.scss index a9b74dae4..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)); @@ -34,19 +38,25 @@ --secondary: light-dark(var(--gray-500), var(--gray-400)); --muted: light-dark(var(--gray-500), var(--gray-400)); - --tertiary: 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}; @@ -62,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/mixins/_functions.scss b/core/scss/mixins/_functions.scss index 2a48ec044..cccd101e4 100644 --- a/core/scss/mixins/_functions.scss +++ b/core/scss/mixins/_functions.scss @@ -40,12 +40,10 @@ @return oklch(from #{$color} calc(l #{$sign} #{math.abs($delta)}) c h); } -/** - * Converts a given value to a percentage string. - * - * @param {Number} $value - The value to be converted to a percentage. - * @return {String} - The percentage representation of the value. - */ +// Converts a given value to a percentage string. +// +// @param {Number} $value - The value to be converted to a percentage. +// @return {String} - The percentage representation of the value. @function to-percentage($value) { @if meta.type-of($value) == number and math.is-unitless($value) { @return math.percentage($value); @@ -53,17 +51,15 @@ @return $value; } -/** - * Generates a transparent version of the given color. - * - * @param {Color} $color - The base color to be made transparent. - * @param {Number} $alpha - The level of transparency, ranging from 0 (fully transparent) to 1 (fully opaque). Default is 1. - * @return {Color} - The resulting color with the specified transparency. - * - * Mixes in `oklab` so tints against an opaque background (e.g. alert backgrounds - * over `--bg-surface`) stay perceptually even; mixing with `transparent` gives - * the same result in every color space. - */ +// Generates a transparent version of the given color. +// +// @param {Color} $color - The base color to be made transparent. +// @param {Number} $alpha - The level of transparency, ranging from 0 (fully transparent) to 1 (fully opaque). Default is 1. +// @return {Color} - The resulting color with the specified transparency. +// +// Mixes in `oklab` so tints against an opaque background (e.g. alert backgrounds +// over `--bg-surface`) stay perceptually even; mixing with `transparent` gives +// the same result in every color space. @function color-transparent($color, $alpha: 1, $background: transparent) { @if $alpha == 1 or $alpha == 100% { @return $color; diff --git a/core/scss/mixins/_mixins.scss b/core/scss/mixins/_mixins.scss index 36b113dde..ff47a32ce 100644 --- a/core/scss/mixins/_mixins.scss +++ b/core/scss/mixins/_mixins.scss @@ -117,3 +117,20 @@ @content; } } + +// `$input-height` and its sizes as a token: the `:root` scale's min height +// (`--btn-input-min-height`) while the input metrics keep their defaults, the +// configured Sass value otherwise. Elements that sit next to a control (an icon +// addon, a select-group tile, an otp slot) match its height through it. +@function input-height-token($size: null) { + @if $size == 'sm' { + $default: add($input-btn-line-height-sm, add(calc($input-btn-padding-y-sm * 2), calc(#{$input-btn-border-width} * 2), false)); + @return if(sass($input-height-sm == $default): var(--btn-input-sm-min-height); else: $input-height-sm); + } + @if $size == 'lg' { + $default: add($input-btn-line-height-lg, add(calc($input-btn-padding-y-lg * 2), calc(#{$input-btn-border-width} * 2), false)); + @return if(sass($input-height-lg == $default): var(--btn-input-lg-min-height); else: $input-height-lg); + } + $default: add($input-btn-line-height, add(calc($input-btn-padding-y * 2), calc(#{$input-btn-border-width} * 2), false)); + @return if(sass($input-height == $default): var(--btn-input-min-height); else: $input-height); +} diff --git a/core/scss/tabler-marketing.scss b/core/scss/tabler-marketing.scss index 798a6d041..123a28806 100644 --- a/core/scss/tabler-marketing.scss +++ b/core/scss/tabler-marketing.scss @@ -5,8 +5,6 @@ @forward 'marketing/browser'; @forward 'marketing/sections'; @forward 'marketing/filters'; -@forward 'marketing/pricing'; -@forward 'marketing/shape'; // This bundle uses its own utilities map instead of the global one, so the // utilities API is emitted via the parameterized mixin (last, as before). 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/tabler-vendors.scss b/core/scss/tabler-vendors.scss index cce656e31..8633982ba 100644 --- a/core/scss/tabler-vendors.scss +++ b/core/scss/tabler-vendors.scss @@ -4,6 +4,7 @@ @forward 'vendor/tom-select'; @forward 'vendor/apexcharts'; @forward 'vendor/jsvectormap'; +@forward 'vendor/maplibre'; @forward 'vendor/dropzone'; @forward 'vendor/fslightbox'; @forward 'vendor/plyr'; 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 806dc266c..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,26 +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", @@ -74,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/_input-height-token.test.scss b/core/scss/tests/_input-height-token.test.scss new file mode 100644 index 000000000..cbabdc5ac --- /dev/null +++ b/core/scss/tests/_input-height-token.test.scss @@ -0,0 +1,14 @@ +// Tests for `input-height-token()` in scss/mixins/_mixins.scss: the height of +// a control as a token that reads the `:root` scale while the input metrics +// keep their defaults. + +@use 'sass:meta'; +@use '../config' as *; + +@include describe('input-height-token') { + @include it('reads the root scale for the default metrics') { + @include assert-equal(meta.inspect(input-height-token()), 'var(--btn-input-min-height)'); + @include assert-equal(meta.inspect(input-height-token('sm')), 'var(--btn-input-sm-min-height)'); + @include assert-equal(meta.inspect(input-height-token('lg')), 'var(--btn-input-lg-min-height)'); + } +} diff --git a/core/scss/tests/_var-if-default.test.scss b/core/scss/tests/_var-if-default.test.scss index c7e3fb4eb..04d9c00e4 100644 --- a/core/scss/tests/_var-if-default.test.scss +++ b/core/scss/tests/_var-if-default.test.scss @@ -13,6 +13,10 @@ @include assert-equal(var-if-default(1rem, 0.5625rem, --btn-input-padding-y), 1rem); } + @include it('takes the token name as a string too') { + @include assert-equal(meta.inspect(var-if-default(1rem, 1rem, '--btn-input-padding-x')), 'var(--btn-input-padding-x)'); + } + @include it('compares var() defaults by value') { @include assert-equal(meta.inspect(var-if-default(var(--border-radius), var(--border-radius), --btn-input-border-radius)), 'var(--btn-input-border-radius)'); @include assert-equal(meta.inspect(var-if-default(0, var(--border-radius), --btn-input-border-radius)), '0'); 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 a29128215..df9125e84 100644 --- a/core/scss/ui/_badges.scss +++ b/core/scss/ui/_badges.scss @@ -33,7 +33,7 @@ @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 e6cc72a9e..050b94eab 100644 --- a/core/scss/ui/_buttons.scss +++ b/core/scss/ui/_buttons.scss @@ -12,10 +12,10 @@ --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}; position: relative; @@ -121,9 +121,9 @@ $btn-colors: map.keys(map.merge($theme-colors, $social-colors)); } #{btn-color-selectors('.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); @@ -132,7 +132,12 @@ $btn-colors: map.keys(map.merge($theme-colors, $social-colors)); --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 map.has-key($theme-colors, 'dark') { @@ -155,6 +160,8 @@ $btn-colors: map.keys(map.merge($theme-colors, $social-colors)); --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('.btn-ghost-{c}', '.btn-ghost.btn-{c}')} { @@ -172,6 +179,7 @@ $btn-colors: map.keys(map.merge($theme-colors, $social-colors)); --btn-disabled-bg: transparent; --btn-disabled-border-color: transparent; --gradient: none; + --btn-gradient: none; --btn-box-shadow: none; } @@ -197,6 +205,7 @@ $btn-colors: map.keys(map.merge($theme-colors, $social-colors)); --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 81d57598c..b3642d3dd 100644 --- a/core/scss/ui/_cards.scss +++ b/core/scss/ui/_cards.scss @@ -160,9 +160,7 @@ } } -/** -Stacked card - */ +// Stacked card .card-stacked { --card-stacked-offset: #{$card-stacked-offset}; position: relative; @@ -418,9 +416,7 @@ Stacked card overflow: auto; } -/** -Card optinos - */ +// Card optinos .card-options { --card-options-top: #{$card-options-top}; --card-options-inset: #{$card-options-inset}; @@ -437,9 +433,7 @@ Card optinos color: var(--secondary); } -/** -Card status - */ +// Card status .card-status-top, .card-status-start, .card-status-bottom { @@ -467,10 +461,10 @@ Card status mask-image: linear-gradient(to top, #000 var(--card-status-size), transparent var(--card-status-size)); } -/** -Card table - */ +// Card table .card-table { + --card-table-top-radius: 0; + --card-table-bottom-radius: 0; margin-bottom: 0 !important; tr { @@ -479,13 +473,11 @@ Card table &:first-child { padding-inline-start: $card-spacer-x; border-inline-start: 0; - border-start-start-radius: var(--card-border-radius); } &:last-child { padding-inline-end: $card-spacer-x; border-inline-end: 0; - border-start-end-radius: var(--card-border-radius); } } } @@ -493,14 +485,26 @@ Card table 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); } } } @@ -545,6 +549,16 @@ Card table } } +.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 */ @@ -584,9 +598,7 @@ Card chart height: var(--card-avatar-height); } -/** -Card avatar - */ +// Card avatar .card-avatar { --card-avatar-ring-width: #{$card-avatar-ring-width}; margin-inline-start: auto; @@ -709,17 +721,13 @@ Card list group } } -/** -Card note -*/ +// Card note .card-note { --card-bg: #{mix-color-css(var(--yellow), var(--bg-surface), 12%)}; --card-border-color: #{mix-color-css(var(--yellow), var(--bg-surface), 20%)}; } -/** -Card gradient -*/ +// Card gradient // Split out so scss/tests/_cards.test.scss can assert on it directly — a bare // top-level ruleset like .card-gradient can't be @include-d or otherwise // referenced in isolation by sass-true. diff --git a/core/scss/ui/_charts.scss b/core/scss/ui/_charts.scss index e005a3a8b..b92b6fc73 100644 --- a/core/scss/ui/_charts.scss +++ b/core/scss/ui/_charts.scss @@ -29,9 +29,7 @@ height: var(--chart-height-square); } -/** -Chart sparkline - */ +// Chart sparkline .chart-sparkline { --chart-sparkline-width: #{$chart-sparkline-width}; --chart-sparkline-height: #{$chart-sparkline-height}; diff --git a/core/scss/ui/_chat.scss b/core/scss/ui/_chat.scss index d9a1633d6..8f9d5dbe9 100644 --- a/core/scss/ui/_chat.scss +++ b/core/scss/ui/_chat.scss @@ -17,7 +17,7 @@ .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 7bd5490e2..23ef49d82 100644 --- a/core/scss/ui/_datagrid.scss +++ b/core/scss/ui/_datagrid.scss @@ -9,6 +9,7 @@ --datagrid-padding: #{$datagrid-padding}; --datagrid-item-width: #{$datagrid-item-width}; --datagrid-title-margin-bottom: #{$datagrid-title-margin-bottom}; + --datagrid-title-width: #{$datagrid-title-width}; // scss-docs-end datagrid-css-vars display: grid; @@ -20,3 +21,26 @@ @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/_datepicker.scss b/core/scss/ui/_datepicker.scss index 21345d705..ff51931e0 100644 --- a/core/scss/ui/_datepicker.scss +++ b/core/scss/ui/_datepicker.scss @@ -19,6 +19,8 @@ --calendar-font-size: #{$datepicker-calendar-font-size}; --calendar-header-font-weight: #{$datepicker-calendar-header-font-weight}; --calendar-weekday-color: var(--secondary); + --calendar-week-number-color: var(--secondary); + --calendar-week-number-border-color: var(--border-color); --calendar-day-hover-bg: var(--hover-bg); --calendar-day-selected-bg: var(--primary); --calendar-day-selected-color: var(--primary-fg); @@ -151,6 +153,12 @@ } } +// Holds the week numbers column, when enabled, next to the month +[data-vc='wrapper'] { + display: flex; + flex-grow: 1; +} + [data-vc='content'] { display: flex; flex-grow: 1; @@ -252,6 +260,46 @@ button[data-vc-week-day] { cursor: pointer; } +// Week numbers: a column in front of the month, with a title that lines up +// with the weekday header and one number for each row of dates. +[data-vc-week='numbers'] { + display: flex; + flex-direction: column; + padding-inline-end: 0.5rem; + margin-inline-end: 0.5rem; + border-inline-end: var(--border-width) solid var(--calendar-week-number-border-color); +} + +[data-vc-week-numbers='title'] { + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 0.5rem; + font-size: var(--calendar-day-font-size); + font-weight: var(--calendar-header-font-weight); + line-height: 1rem; + color: var(--calendar-week-number-color); +} + +[data-vc-week-numbers='content'] { + display: grid; + grid-auto-rows: calc(var(--calendar-day-size) + 0.25rem); + align-items: center; + justify-items: center; +} + +[data-vc-week-number] { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + min-width: var(--calendar-day-size); + font-size: var(--calendar-day-font-size); + font-weight: $font-weight-normal; + line-height: 1rem; + color: var(--calendar-week-number-color); +} + // Dates grid [data-vc='dates'] { pointer-events: none; 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 c53b8ae53..2b2b304c8 100644 --- a/core/scss/ui/_forms.scss +++ b/core/scss/ui/_forms.scss @@ -7,9 +7,7 @@ textarea { } } -/** -Form label - */ +// Form label .col-form-label, .form-label { --form-label-required-gap: #{$form-label-required-gap}; @@ -32,18 +30,14 @@ Form label color: $form-secondary-color; } -/** -Form select - */ +// Form select .form-select { &:-moz-focusring { color: var(--body-color); } } -/** -Form control - */ +// Form control .form-control { &:-webkit-autofill { color: var(--body-color); @@ -53,7 +47,7 @@ Form control &:disabled, &.disabled { - color: $form-secondary-color; + color: var(--control-disabled-color); user-select: none; } @@ -63,7 +57,7 @@ Form control } .form-control-light { - background-color: var(--gray-100); + background-color: var(--bg-surface-secondary); border-color: transparent; } @@ -117,9 +111,7 @@ fieldset:empty { display: none; } -/** -Form help - */ +// Form help .form-help { --form-help-size: #{$form-help-size}; --form-help-font-size: #{$form-help-font-size}; @@ -138,7 +130,7 @@ Form help 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); @@ -150,12 +142,10 @@ Form help } } -/** -Input group - */ +// Input group .input-group { box-shadow: $input-box-shadow; - @include border-radius($input-border-radius); + @include border-radius(var(--input-group-addon-border-radius)); .form-control, .form-select, @@ -173,7 +163,7 @@ Input group &:focus-within { --focus-ring-offset: -1px; @include focus-ring(true); - @include border-radius($input-border-radius); + @include border-radius(var(--input-group-addon-border-radius)); .form-control, .input-group-text { @@ -183,7 +173,7 @@ Input group .form-control { &:focus { - border-color: $input-border-color; + border-color: var(--control-border-color); outline: 0; box-shadow: none; } @@ -199,7 +189,7 @@ Input group .input-group-text { z-index: 10; - background: $form-check-input-bg; + background: #{var-if-default($form-check-input-bg, var(--bg-forms), '--btn-input-bg')}; @include transition($input-transition); &:first-child { @@ -235,17 +225,13 @@ Input group } } -/** -Upload files - */ +// Upload files .form-file-button { margin-inline-start: 0; border-inline-start: 0; } -/** -Forms on mobile devices - */ +// Forms on mobile devices .form-control, .form-select { --form-control-mobile-font-size: #{$form-control-mobile-font-size}; 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/_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/_legend.scss b/core/scss/ui/_legend.scss index cb58e2907..46c82da06 100644 --- a/core/scss/ui/_legend.scss +++ b/core/scss/ui/_legend.scss @@ -1,6 +1,4 @@ -/** -Legend - */ +// Legend @use 'sass:map'; @use '../config' as *; diff --git a/core/scss/ui/_lists.scss b/core/scss/ui/_lists.scss index f96d636d0..351e563e5 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: $component-active-bg; - 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 $component-active-bg; +} + +// 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 { @@ -96,18 +186,14 @@ } } -/** -Seprated list -*/ +// Seprated list .list-separated { display: flex; flex-direction: column; gap: $spacer; } -/** -Inline list - */ +// Inline list .list-inline { margin: 0; } @@ -118,3 +204,42 @@ Inline list 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/_loaders.scss b/core/scss/ui/_loaders.scss index e3a8b528e..88c9c9cd1 100644 --- a/core/scss/ui/_loaders.scss +++ b/core/scss/ui/_loaders.scss @@ -26,9 +26,7 @@ } } -/** -Dimmer -*/ +// Dimmer .dimmer { position: relative; diff --git a/core/scss/ui/_markdown.scss b/core/scss/ui/_markdown.scss index f1eff867b..88a6755e6 100644 --- a/core/scss/ui/_markdown.scss +++ b/core/scss/ui/_markdown.scss @@ -3,9 +3,7 @@ $markdown-font-size: var(--font-size-h3) !default; $markdown-line-height: var(--line-height-lg) !default; -/** -Prose (formerly markdown) - */ +// Prose (formerly markdown) .prose, .markdown { --markdown-heading-margin-top: #{$markdown-heading-margin-top}; 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/_progress.scss b/core/scss/ui/_progress.scss index 0ad899dab..20e03867f 100644 --- a/core/scss/ui/_progress.scss +++ b/core/scss/ui/_progress.scss @@ -13,9 +13,7 @@ } } -/** -Progress - */ +// Progress .progress { position: relative; width: 100%; @@ -52,9 +50,7 @@ Progress --progress-height: #{$progress-height-xl}; } -/** -Progress bar - */ +// Progress bar .progress-bar { height: 100%; @include transition(width $transition-time, background $transition-time); @@ -86,9 +82,7 @@ Progress bar } } -/** -Progressbg - */ +// Progressbg .progressbg { --progressbg-padding-y: #{$progressbg-padding-y}; --progressbg-padding-x: #{$progressbg-padding-x}; @@ -124,9 +118,7 @@ Progressbg font-weight: var(--font-weight-medium); } -/** -Progress steps - */ +// Progress steps .progress-steps { // scss-docs-start progress-steps-css-vars --progress-steps-gap: #{$progress-steps-gap}; diff --git a/core/scss/ui/_segmented.scss b/core/scss/ui/_segmented.scss index 060d48323..a994673fb 100644 --- a/core/scss/ui/_segmented.scss +++ b/core/scss/ui/_segmented.scss @@ -14,6 +14,7 @@ --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; display: inline-flex; flex-wrap: wrap; gap: var(--nav-gap); 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/_sparkline.scss b/core/scss/ui/_sparkline.scss index e420e685f..7bc93cc23 100644 --- a/core/scss/ui/_sparkline.scss +++ b/core/scss/ui/_sparkline.scss @@ -1,6 +1,4 @@ -/** -Sparkline (inline SVG, rendered by js/src/sparkline.ts) - */ +// Sparkline (inline SVG, rendered by js/src/sparkline.ts) @use 'sass:map'; @use '../config' as *; diff --git a/core/scss/ui/_tables.scss b/core/scss/ui/_tables.scss index 033647306..9e2e7e721 100644 --- a/core/scss/ui/_tables.scss +++ b/core/scss/ui/_tables.scss @@ -112,9 +112,7 @@ } } -/** -Table sort - */ +// Table sort .table-sort { --table-sort-icon-size: #{$table-sort-icon-size}; --table-sort-icon-margin-start: #{$table-sort-icon-margin-start}; 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 966f6f7e1..fbb4401b3 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; @@ -184,9 +171,7 @@ img { margin-inline-start: 0; } -/** -Selection - */ +// Selection ::selection, .text-selected { background-color: $selection-bg; @@ -196,9 +181,7 @@ Selection display: inline-block; } -/** -Links - */ +// Links [class^='link-'], [class*=' link-'] { &.disabled { @@ -222,16 +205,12 @@ a:hover:has(.icon) { } } -/** -Subheader - */ +// Subheader .subheader { @include subheader(); } -/** -Mentions - */ +// Mentions .mention { display: inline-block; padding: calc(2em / 12) calc(8em / 12); 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 216df88a9..f78be59ba 100644 --- a/core/scss/ui/forms/_form-check.scss +++ b/core/scss/ui/forms/_form-check.scss @@ -24,11 +24,9 @@ Form check } .form-check-input { - --form-check-input-width: #{$form-check-input-width}; - margin-top: ($form-check-min-height - $form-check-input-width) * 0.5; - + margin-top: calc((#{$form-check-min-height} - var(--form-check-input-width)) / 2); background-size: var(--form-check-input-width); - box-shadow: $form-check-input-box-shadow; + box-shadow: var(--form-check-box-shadow); } .form-check-label { @@ -67,15 +65,15 @@ Form switch */ .form-switch { .form-check-input { - height: $form-switch-height; - margin-top: ($form-check-min-height - $form-switch-height) * 0.5; + height: var(--form-switch-height); + margin-top: calc((#{$form-check-min-height} - var(--form-switch-height)) / 2); @include transition(background-color $transition-time, background-position $transition-time, $focus-ring-transition); // An off switch that is disabled fills its track like a disabled input, // so it no longer looks like an off switch that can still be toggled. &:disabled:not(:checked) { - --form-switch-color: #{$form-switch-disabled-color}; - background-color: $form-switch-disabled-bg; + --form-switch-color: var(--form-switch-disabled-color); + background-color: var(--form-switch-disabled-bg); } } } @@ -93,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-custom.scss b/core/scss/ui/forms/_form-custom.scss index 5b72608f7..6e1b2542b 100644 --- a/core/scss/ui/forms/_form-custom.scss +++ b/core/scss/ui/forms/_form-custom.scss @@ -1,6 +1,4 @@ -/** -Bootstrap color input - */ +// Bootstrap color input @use '../../config' as *; .form-control-color { @@ -9,9 +7,7 @@ Bootstrap color input } } -/** -Remove the cancel buttons in Chrome and Safari on macOS. - */ +// Remove the cancel buttons in Chrome and Safari on macOS. [type='search']::-webkit-search-cancel-button { appearance: none; } diff --git a/core/scss/ui/forms/_form-icon.scss b/core/scss/ui/forms/_form-icon.scss index 0da902cfe..73c9acd78 100644 --- a/core/scss/ui/forms/_form-icon.scss +++ b/core/scss/ui/forms/_form-icon.scss @@ -1,11 +1,10 @@ -/** -Icon input - */ +// Icon input @use '../../config' as *; .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), @@ -17,6 +16,14 @@ Icon input .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 { @@ -30,7 +37,7 @@ Icon input align-items: center; justify-content: center; min-width: var(--input-icon-addon-width); - height: $input-height; + 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-imagecheck.scss b/core/scss/ui/forms/_form-imagecheck.scss index 356b47743..4ce0e44fb 100644 --- a/core/scss/ui/forms/_form-imagecheck.scss +++ b/core/scss/ui/forms/_form-imagecheck.scss @@ -1,6 +1,4 @@ -/** -Image check - */ +// Image check @use '../../config' as *; .form-imagecheck { 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/ui/forms/_form-otp.scss b/core/scss/ui/forms/_form-otp.scss index d82bfe2ed..36fa792a7 100644 --- a/core/scss/ui/forms/_form-otp.scss +++ b/core/scss/ui/forms/_form-otp.scss @@ -114,7 +114,7 @@ .otp-input:disabled ~ .otp-slots .otp-slot { color: var(--disabled-color); - background-color: $input-disabled-bg; + background-color: #{var-if-default($input-disabled-bg, var(--bg-forms-disabled), '--control-disabled-bg')}; } // Zero gap with shared, merged borders; only the first and last slot keep a @@ -149,12 +149,12 @@ } .otp-sm { - --otp-size: #{$input-height-sm}; + --otp-size: #{input-height-token('sm')}; --otp-font-size: #{$input-font-size-sm}; } .otp-lg { - --otp-size: #{$input-height-lg}; + --otp-size: #{input-height-token('lg')}; --otp-font-size: #{$input-font-size-lg}; } diff --git a/core/scss/ui/forms/_form-selectgroup.scss b/core/scss/ui/forms/_form-selectgroup.scss index 2e8bcdc4a..59fa02ebb 100644 --- a/core/scss/ui/forms/_form-selectgroup.scss +++ b/core/scss/ui/forms/_form-selectgroup.scss @@ -36,11 +36,11 @@ Select group .form-selectgroup-label { position: relative; display: block; - min-width: $input-height; - padding: $input-btn-padding-y $input-btn-padding-x; + min-width: input-height-token(); + padding: var(--btn-input-padding-y) var(--btn-input-padding-x); margin: 0; - font-size: $input-btn-font-size; - line-height: $input-line-height; + font-size: var(--btn-input-font-size); + line-height: #{var-if-default($input-line-height, $input-btn-line-height, '--btn-input-line-height')}; color: var(--secondary); text-align: center; cursor: pointer; @@ -95,8 +95,8 @@ Select group .form-selectgroup-check-floated { position: absolute; - inset-inline-end: $input-btn-padding-y; - top: $input-btn-padding-y; + inset-inline-end: var(--btn-input-padding-y); + top: var(--btn-input-padding-y); } .form-selectgroup-input:checked + .form-selectgroup-label { @@ -113,9 +113,7 @@ Select group @include focus-ring(true); } -/** -Alternate version of form select group - */ +// Alternate version of form select group .form-selectgroup-boxes { .form-selectgroup-label { padding: $card-spacer-y $card-spacer-x; @@ -138,9 +136,7 @@ Alternate version of form select group } } -/** -Select group - */ +// Select group .form-selectgroup-pills { flex-wrap: wrap; align-items: start; diff --git a/core/scss/ui/forms/_validation.scss b/core/scss/ui/forms/_validation.scss index 3e8edb4af..d16aeeb39 100644 --- a/core/scss/ui/forms/_validation.scss +++ b/core/scss/ui/forms/_validation.scss @@ -11,7 +11,7 @@ // `.btn`/`.btn-sm`/`.btn-lg` use for their own icons (see ui/_buttons.scss). %validation-icon-size { --form-feedback-icon-size: #{$input-btn-icon-size}; - --form-feedback-icon-inset: #{$input-padding-x}; + --form-feedback-icon-inset: #{var-if-default($input-padding-x, $input-btn-padding-x, '--btn-input-padding-x')}; padding-inline-end: calc(var(--form-feedback-icon-size) + var(--form-feedback-icon-inset) * 2) !important; background-position: right var(--form-feedback-icon-inset) center !important; background-size: var(--form-feedback-icon-size) var(--form-feedback-icon-size) !important; @@ -19,12 +19,12 @@ %validation-icon-size-sm { --form-feedback-icon-size: #{$input-btn-icon-size-sm}; - --form-feedback-icon-inset: #{$input-padding-x-sm}; + --form-feedback-icon-inset: #{var-if-default($input-padding-x-sm, $input-btn-padding-x-sm, '--btn-input-sm-padding-x')}; } %validation-icon-size-lg { --form-feedback-icon-size: #{$input-btn-icon-size-lg}; - --form-feedback-icon-inset: #{$input-padding-x-lg}; + --form-feedback-icon-inset: #{var-if-default($input-padding-x-lg, $input-btn-padding-x-lg, '--btn-input-lg-padding-x')}; } %validation-icon-size-textarea { diff --git a/core/scss/ui/typo/_hr.scss b/core/scss/ui/typo/_hr.scss index 7ce983756..072e1a0c6 100644 --- a/core/scss/ui/typo/_hr.scss +++ b/core/scss/ui/typo/_hr.scss @@ -1,15 +1,11 @@ -/** -Horizontal rules - */ +// Horizontal rules @use '../../config' as *; // The `.hr` helper lives in `_extends.scss` — it extends the `hr` element // selector across all modules, which with the module system requires the // whole core to be upstream. -/** -Hr text - */ +// Hr text .hr-text { --hr-text-gap: #{$hr-text-gap}; display: flex; diff --git a/core/scss/utils/_colors.scss b/core/scss/utils/_colors.scss index c3d86e062..913f99cb6 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/utils/_text.scss b/core/scss/utils/_text.scss index cca3bbb3d..3f4a3a80e 100644 --- a/core/scss/utils/_text.scss +++ b/core/scss/utils/_text.scss @@ -1,8 +1,6 @@ // stylelint-disable declaration-no-important -/** -Antialiasing - */ +// Antialiasing @use '../config' as *; .antialiased { diff --git a/core/scss/vendor/_maplibre.scss b/core/scss/vendor/_maplibre.scss new file mode 100644 index 000000000..166508fe6 --- /dev/null +++ b/core/scss/vendor/_maplibre.scss @@ -0,0 +1,290 @@ +@use '../config' as *; + +.maplibregl-map { + font-family: inherit; + font-size: $h5-font-size; + line-height: $line-height-base; +} + +// Tabler buttons over the map, in the top end corner +.map-controls { + position: absolute; + inset-block-start: #{$map-controls-offset}; + inset-inline-end: #{$map-controls-offset}; + z-index: 2; + display: flex; + flex-direction: column; + gap: #{$map-controls-gap}; +} + +// The hover and active colors of a button are see-through, which would show +// the map under it. For a button inside a map, or in an element laid over one, +// keep the button color and draw the hover color as a layer on top. +[data-bs-toggle='map'], +[data-bs-toggle='map'] ~ *, +[data-tblr-toggle='map'], +[data-tblr-toggle='map'] ~ * { + .btn:hover { + background-color: var(--btn-bg); + background-image: linear-gradient(var(--btn-hover-bg), var(--btn-hover-bg)); + } + + .btn:active, + .btn.active { + background-color: var(--btn-bg); + background-image: linear-gradient(var(--btn-active-bg), var(--btn-active-bg)); + } +} + +// A map button with no content gets its icon from its action +[data-bs-map-action]:empty::before { + width: #{$map-control-icon-size}; + height: #{$map-control-icon-size}; + content: ''; + background: currentColor; + mask: var(--map-control-icon) center / contain no-repeat; +} + +@each $action, $shape in $map-control-icons { + [data-bs-map-action='#{$action}'] { + --map-control-icon: #{escape-svg(url("data:image/svg+xml,#{$shape}"))}; + } +} + +// Attribution +.maplibregl-ctrl.maplibregl-ctrl-attrib { + color: var(--secondary); + background-color: color-transparent(var(--bg-surface), 0.7); + + a { + color: inherit; + } +} + +.maplibregl-ctrl-attrib.maplibregl-compact { + color: var(--secondary); + background-color: var(--bg-surface); +} + +.maplibregl-ctrl-attrib-button:focus { + box-shadow: none; + @include focus-ring(); +} + +.maplibregl-ctrl-scale { + color: var(--body-color); + background-color: color-transparent(var(--bg-surface), 0.75); + border-color: var(--body-color); +} + +// Popup: a small bordered panel with no arrow +.maplibregl-popup .maplibregl-popup-content { + padding: 0.75rem; + color: var(--body-color); + background: var(--bg-surface); + box-shadow: + 0 0 0 var(--border-width) var(--border-color), + #{$box-shadow-md}; + @include border-radius(var(--border-radius)); +} + +.maplibregl-popup-tip { + display: none; +} + +// A popup fades in and grows a little as it opens +.maplibregl-popup-content { + animation: map-popup-in 0.2s ease-out; + + @media (prefers-reduced-motion: reduce) { + animation: none; + } +} + +@keyframes map-popup-in { + from { + opacity: 0; + transform: scale(0.95); + } +} + +// Room for the close button, which MapLibre pins to the right edge in both directions +.maplibregl-popup .maplibregl-popup-content:has(.maplibregl-popup-close-button) { + /* rtl:ignore */ + padding-right: 2rem; +} + +.maplibregl-popup-close-button { + width: 1.25rem; + height: 1.25rem; + padding: 0; + margin: 0.25rem; + font-size: 1rem; + line-height: 1; + color: var(--secondary); + @include border-radius(var(--border-radius-sm)); + + &:hover { + color: var(--body-color); + background-color: var(--bg-surface-secondary); + } +} + +// Popup used as a tooltip: a short inverted label, shown on hover +.map-tooltip .maplibregl-popup-content { + padding: 0.25rem 0.5rem; + color: var(--text-inverted); + pointer-events: none; + background: var(--bg-surface-inverted); + box-shadow: #{$box-shadow-md}; +} + +// Popup that holds a card-like block: an image on top, padded content below +.map-popup-card.maplibregl-popup { + .maplibregl-popup-content { + width: #{$map-popup-card-width}; + padding: 0; + overflow: hidden; + } + + .maplibregl-popup-close-button { + color: var(--body-color); + background-color: var(--bg-surface); + } +} + +// Markers written as children of the map element stay hidden until the +// component moves them onto the map +[data-bs-toggle='map'] > [data-bs-lng], +[data-tblr-toggle='map'] > [data-tblr-lng] { + display: none; +} + +// Marker drawn in HTML. MapLibre moves the marker with `transform`, so the +// shape is drawn by the pseudo-element and the root keeps no transform. +.map-marker { + // scss-docs-start map-marker-css-vars + --map-marker-size: #{$map-marker-size}; + --map-marker-bg: #{$map-marker-bg}; + --map-marker-color: #{$map-marker-color}; + --map-marker-border-width: #{$map-marker-border-width}; + --map-marker-border-color: #{$map-marker-border-color}; + --map-marker-font-size: #{$map-marker-font-size}; + --map-marker-icon-size: #{$map-marker-icon-size}; + // scss-docs-end map-marker-css-vars + position: relative; + z-index: 0; + display: inline-flex; + align-items: center; + justify-content: center; + min-width: var(--map-marker-size); + min-height: var(--map-marker-size); + padding: var(--map-marker-border-width); + font-size: var(--map-marker-font-size); + font-weight: var(--font-weight-medium); + line-height: 1; + color: var(--map-marker-color); + cursor: pointer; + + &::before { + position: absolute; + inset: 0; + z-index: -1; + content: ''; + background: var(--map-marker-bg); + filter: #{$map-marker-shadow}; + border: var(--map-marker-border-width) solid var(--map-marker-border-color); + @include border-radius(var(--border-radius-pill)); + } + + // On a map the marker is positioned by MapLibre + &.maplibregl-marker { + position: absolute; + } + + .icon { + --icon-size: var(--map-marker-icon-size); + } +} + +.map-marker-dot { + --map-marker-size: #{$map-marker-dot-size}; +} + +// A ring that grows out of the marker and fades, for a live position +.map-marker-pulse::after { + position: absolute; + inset: 0; + z-index: -2; + content: ''; + background: var(--map-marker-bg); + animation: map-marker-pulse 1.6s ease-out infinite; + @include border-radius(var(--border-radius-pill)); + + @media (prefers-reduced-motion: reduce) { + animation: none; + } +} + +@keyframes map-marker-pulse { + from { + opacity: 0.5; + } + + to { + opacity: 0; + transform: scale(2.6); + } +} + +// Short text under a marker +.map-marker-label { + position: absolute; + top: 100%; + left: 50%; + margin-top: 0.125rem; + font-size: $h6-font-size; + font-weight: var(--font-weight-medium); + line-height: 1.2; + color: var(--body-color); + text-shadow: + 0 0 2px var(--bg-surface), + 0 0 2px var(--bg-surface), + 0 0 4px var(--bg-surface); + white-space: nowrap; + transform: translateX(-50%); +} + +// Teardrop: a square with one sharp corner, turned to point down. Its tip +// reaches 20.7% below the circle, so the root is that much taller. +.map-marker-pin { + box-sizing: content-box; + width: var(--map-marker-size); + min-width: 0; + height: var(--map-marker-size); + min-height: 0; + padding: 0 0 calc(var(--map-marker-size) * 0.207); + + &::before { + bottom: auto; + height: var(--map-marker-size); + @include border-radius(50% 50% 50% 0); + transform: rotate(-45deg); + } +} + +@each $name, $color in $theme-colors { + .map-marker-#{$name} { + --map-marker-bg: var(--#{$name}); + --map-marker-color: var(--#{$name}-fg); + } +} + +// The attribution icon is a black SVG image +@if $enable-dark-mode { + @include color-mode(dark) { + .maplibregl-ctrl-attrib-button { + @include autodark-image(); + } + } +} diff --git a/core/scss/vendor/_tom-select.scss b/core/scss/vendor/_tom-select.scss index 82a61ec63..07c4477b3 100644 --- a/core/scss/vendor/_tom-select.scss +++ b/core/scss/vendor/_tom-select.scss @@ -72,50 +72,39 @@ // The bootstrap5 preset hardcodes Bootstrap's own input metrics on // `.ts-control` (0.375rem 0.75rem padding, 1.5 line-height), so a Tom Select -// came out 5px shorter than a `.form-control` next to it (#2567). Use Tabler's -// input tokens instead, in every size. The multi-value control follows the -// preset's formula: take the item's 1px padding, 1px border and 3px bottom -// margin out of the control's padding so the chips fill the same height. +// came out 5px shorter than a `.form-control` next to it (#2567). The wrapper +// carries the `.form-control` / `.form-select` class, so it holds the +// `--control-*` tokens: read them and every size follows on its own. The +// multi-value control follows the preset's formula: take the item's 1px +// 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. 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: $input-padding-y $input-padding-x; - font-size: $input-font-size; - line-height: $input-line-height; + 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 { - padding-top: calc(#{$input-padding-y} - 2px) !important; - padding-bottom: calc(#{$input-padding-y} - 5px); + padding-top: calc(var(--control-padding-y) - 2px) !important; + padding-bottom: calc(var(--control-padding-y) - 5px); } +} - &.form-control-sm, - &.form-select-sm { - .ts-control { - padding: $input-padding-y-sm $input-padding-x-sm; - font-size: $input-font-size-sm; - line-height: $input-line-height-sm; - } - - &.multi.has-items .ts-control { - padding-top: calc(#{$input-padding-y-sm} - 2px) !important; - padding-bottom: calc(#{$input-padding-y-sm} - 5px); - } - } - - &.form-control-lg, - &.form-select-lg { - .ts-control { - padding: $input-padding-y-lg $input-padding-x-lg; - font-size: $input-font-size-lg; - line-height: $input-line-height-lg; - } - - &.multi.has-items .ts-control { - padding-top: calc(#{$input-padding-y-lg} - 2px) !important; - padding-bottom: calc(#{$input-padding-y-lg} - 5px); - } - } +// 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 @@ -172,7 +161,7 @@ .ts-control input { &::placeholder { - color: $input-placeholder-color; + color: var(--control-placeholder-color, #{$input-placeholder-color}); } } @@ -185,12 +174,28 @@ } } -.ts-wrapper.disabled .ts-control { - background-color: var(--secondary-bg); - opacity: 1; +.ts-wrapper.disabled { + .ts-control { + background-color: var(--secondary-bg); + opacity: 1; - & > div.item { - color: var(--gray-500); + & > div.item { + color: var(--gray-500); + } + } + + // The `.form-control` / `.form-select` wrapper draws the rounded border, so a + // background on the `.ts-control` inside it spilled over the corners. Paint the + // disabled state on the wrapper with the tokens of a native `:disabled` control, + // which also covers a single select with a value (`.full`, painted `--body-bg`). + &.form-control, + &.form-select { + background-color: var(--control-disabled-bg); + border-color: var(--control-disabled-border-color); + + .ts-control { + background-color: transparent; + } } } diff --git a/core/tsconfig.types.json b/core/tsconfig.types.json index b5e5e1e66..6dda2fec3 100644 --- a/core/tsconfig.types.json +++ b/core/tsconfig.types.json @@ -8,5 +8,5 @@ "rootDir": "js", "outDir": "dist/types" }, - "include": ["js/tabler.ts", "js/tabler-theme.ts", "js/src/**/*"] + "include": ["js/tabler.ts", "js/tabler-theme.ts", "js/tabler-map.ts", "js/tabler-vector-map.ts", "js/maps/*.ts", "js/src/**/*"] } diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 1886c5b52..a640b7961 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,5 +1,44 @@ # @tabler/docs +## 1.7.0 + +### Minor Changes + +- ace9a4f: Added `.datagrid-horizontal` and `.datagrid-cols-{n}` classes and more datagrid examples and docs. +- 6cd45d0: Added the Icon design guide page to the Tabler Icons docs, with the SVG format, naming rules and icon requests. +- 8f00eab: Added the Forms → CSS variables page: the control scale, state colors and every `--tblr-control-*`, add-on, floating, range, check and switch token. +- f7c848a: Added the Hover gallery component with `.hover-gallery`, `.hover-gallery-item` and `.hover-gallery-indicators` classes. +- c493436: Added the `.list-icon` component for lists with icon markers, with `.list-icon-inline` for entries in a row. +- 681b6eb: Added the image label component: `.img-labeled` and `.img-label` pin a short text or an icon to a corner of an image. +- 03e2775: Updated `` to a compact key cap and added `.kbd-group` and `--tblr-kbd-*` variables for keyboard shortcuts. +- a845ab0: Added the maps page demos on MapLibre instead of Mapbox GL and Google Maps, and the Map blocks page with seven blocks such as a store locator. +- a845ab0: Added the `MapView` component in `tabler-map.js` on MapLibre GL JS 6: `data-bs-toggle="map"` makes a themed map with markers, popups, routes and a globe. +- d6cfffb: Added the `.mockup-browser` component that frames a screenshot or any content in a browser window with an address bar. +- 6c46d55: Added `NavOverflow` component that moves nav items that do not fit into a More dropdown, with a `.nav-overflow` wrapper. +- 3f3aae9: Added the `NumberInput` component, a `.number-input` quantity input with buttons, bounds, hold-to-repeat and keyboard support. +- 744f9fe: Added the `.text-shimmer` class that sweeps a highlight across a label, with an optional `.text-shimmer-loader` spinner. +- 8ab6441: Added the `VectorMap` plugin: an SVG world map with values, color scales, a legend, markers, lines, selection, tooltips and zoom. + +### Patch Changes + +- 964eb7c: Added `dateFormat` patterns such as `iso` or `DD.MM.YYYY` and a `boundInput` option for ISO form values to `Datepicker`. +- 725e45d: Added the `weekNumbers` option to `Datepicker` to show the week number in front of each row. +- d07533c: Updated the 1.7 upgrade guide with the visual changes, the Shape menu entry, `combined` navbar position, flag aliases and `--tblr-kbd-*` variables. +- 0647af1: Added the `--tblr-fill-*` tokens and `.bg-fill-*` utilities, used by badges, avatars, code, progress and range tracks and the hover background. +- 8f00eab: Added `--tblr-form-check-*` and `--tblr-form-switch-*` variables, with shared `--tblr-control-checked-bg` and disabled tokens on `:root`. +- 8f00eab: Added `--tblr-control-*` variables to `.form-control` and `.form-select`; sizes and the file button now follow them. +- bb60211: Updated `.form-switch` with a filled gray off track, a white knob with an even gap and a new `$form-switch-bg` variable. +- ba4e87a: Added `.icon-inline` to icons that sit in a line of text in the demo cards, preview pages and docs examples. +- 68da446: Updated the flags plugin to Tabler Flags: 304 flags at a 5:4 ratio, 50 new like `.flag-country-nato`, old codes kept as aliases. +- 8f00eab: Added `--tblr-input-group-addon-*`, `--tblr-form-floating-*` and `--tblr-range-*` variables to input groups, floating labels and range inputs. +- 9d837ea: Fixed `.input-icon` icons off-center in small and large inputs and selects: the addon follows the control size. +- 69c1114: Added item slots, `.list-group-sm` and `.list-group-lg` sizes, selectable and nested items to the list group. +- 5cbc443: Added a `mist` gray palette for `data-bs-theme-base` and removed the hidden `pink` base from `tabler-themes.css`. +- 00746f2: Added `.input-icon` support for Tom Select: a leading icon no longer overlaps the selected value. +- ed41f04: Updated the promo top banner to load only on production deploys, not on branch previews. +- b1aac09: Updated Tabler Illustrations to v1.18.0 with 11 new illustrations. +- 8dc1ffe: Updated Tabler Icons to v3.49.0. + ## 1.6.1 ### Patch Changes diff --git a/docs/components/Example.astro b/docs/components/Example.astro index bef3dd00b..cfd962051 100644 --- a/docs/components/Example.astro +++ b/docs/components/Example.astro @@ -27,9 +27,11 @@ interface Props { columnFullWidth?: boolean hideCode?: boolean codeOnly?: boolean + /** no padding around the demo, for content that fills the whole area, such as a map */ + flush?: boolean } -const { html: htmlProp, code, raw, overflow = 'auto', background = 'surface-secondary', class: className, height, column, centered, vertical, columnFullWidth, hideCode, codeOnly } = Astro.props +const { html: htmlProp, code, raw, overflow = 'auto', background = 'surface-secondary', class: className, height, column, centered, vertical, columnFullWidth, hideCode, codeOnly, flush } = Astro.props const backgroundClasses: Record = { 'surface-secondary': 'bg-surface-secondary', @@ -52,7 +54,7 @@ const hasDemo = !codeOnly const hasCode = !hideCode const exampleClasses = ['example fs-base border', hasCode ? 'rounded-top' : 'rounded', !raw && 'd-flex flex-wrap justify-content-center', `overflow-${overflow}`, 'position-relative', backgroundClasses[background], className] -const innerClasses = ['p-6 w-full', column && 'd-flex gap-3 flex-column', !column && centered && `d-flex flex-fill flex-wrap gap-2 justify-content-center${vertical ? ' align-items-center flex-column' : ' justify-content-center'}`, columnFullWidth && 'd-flex flex-fill flex-column gap-2'] +const innerClasses = [flush ? 'p-0' : 'p-6', 'w-full', column && 'd-flex gap-3 flex-column', !column && centered && `d-flex flex-fill flex-wrap gap-2 justify-content-center${vertical ? ' align-items-center flex-column' : ' justify-content-center'}`, columnFullWidth && 'd-flex flex-fill flex-column gap-2'] const highlighted = hideCode ? '' : await highlightCode(beautifyHtml(code ?? html), 'html') --- diff --git a/docs/components/Flags.astro b/docs/components/Flags.astro index 8c2f20c04..5d8da808e 100644 --- a/docs/components/Flags.astro +++ b/docs/components/Flags.astro @@ -3,39 +3,52 @@ import MarkdownSource from './MarkdownSource.astro' import { markdownTable } from '@shared/lib/markdown-source' import flags from '@data/flags.json' -// Same list as a table for the page's .md mirror -const markdown = markdownTable( - ['Country', 'Code', 'CSS class'], - flags.map((flag) => [flag.name, `\`${flag.flag}\``, `\`.flag-country-${flag.flag}\``]), -) +const categories = ['Countries', 'Territories', 'Unions', 'Other'] +const groups = categories.map((category) => ({ category, flags: flags.filter((flag) => flag.category === category) })) + +// Same list as tables for the page's .md mirror +const markdown = groups + .map(({ category, flags }) => { + const table = markdownTable( + ['Name', 'Code', 'CSS class'], + flags.map((flag) => [flag.name, `\`${flag.flag}\``, `\`.flag-country-${flag.flag}\``]), + ) + return `### ${category}\n\n${table}` + }) + .join('\n\n') --- - - - - - - - - - - - { - flags.map((flag) => ( - - - - - - - )) - } - -
FlagNameCountry codeCSS class
-
-
{flag.name} - {flag.flag} - - .flag-country-{flag.flag} -
+{ + groups.map(({ category, flags }) => ( + +

{category}

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