diff --git a/.bundlewatch.config.json b/.bundlewatch.config.json index 37ab18193..619d0ecb2 100644 --- a/.bundlewatch.config.json +++ b/.bundlewatch.config.json @@ -26,35 +26,35 @@ }, { "path": "./dist/css/coreui.css", - "maxSize": "73.5 kB" + "maxSize": "74 kB" }, { "path": "./dist/css/coreui.min.css", - "maxSize": "69.25 kB" + "maxSize": "69.75 kB" }, { "path": "./dist/js/coreui.bundle.js", - "maxSize": "129KB" + "maxSize": "130KB" }, { "path": "./dist/js/coreui.bundle.min.js", - "maxSize": "83KB" + "maxSize": "84KB" }, { "path": "./dist/js/coreui.esm.js", - "maxSize": "118KB" + "maxSize": "119KB" }, { "path": "./dist/js/coreui.esm.min.js", - "maxSize": "85KB" + "maxSize": "86KB" }, { "path": "./dist/js/coreui.js", - "maxSize": "119KB" + "maxSize": "120KB" }, { "path": "./dist/js/coreui.min.js", - "maxSize": "77KB" + "maxSize": "78KB" } ], "ci": { diff --git a/docs/src/content/docs/components/progress.mdx b/docs/src/content/docs/components/progress.mdx index b00a36f38..a56861158 100644 --- a/docs/src/content/docs/components/progress.mdx +++ b/docs/src/content/docs/components/progress.mdx @@ -99,6 +99,25 @@ The class works one level up as well — put it on the `.progress` and every bar A [`.bg-*` utility](/utilities/background/) still paints a bar, but it sets the fill alone — the label keeps the default foreground colour rather than the one that reads on the fill. +## Gradients + + + +Set `--cui-progress-bar-bg-image` to paint the bar with a gradient instead of a flat colour. The image is sized to the bar, so the whole gradient is visible at any width and the label keeps the colour set by the theme. Set it on the `.progress` to share one gradient between every bar inside, or on a single `.progress-bar`. + + +
+ +
+
70%
+
`} /> + +Stripes are a second layer over the gradient, so `.progress-bar-striped` and `.progress-bar-animated` combine with it. Change the stripe pattern itself with `--cui-progress-bar-stripes`, or just its colour with `--cui-progress-bar-stripe-color`. + + +
+ `} /> + ## Multiple bars Include multiple progress bars in a progress component if you need. @@ -125,6 +144,43 @@ Include multiple progress bars in a progress component if you need. `} /> +## Segmented + + + +Add `.progress-segmented` to a `.progress` or `.progress-stacked` to cut the track into a row of rounded segments. It is a CSS mask over the whole component, so the bar underneath keeps its transition, theme, gradient and stripes; only the rendering changes. Segments are `--cui-progress-segment-min-width` wide (.25rem) and `--cui-progress-segment-gap` apart, as many as fit; set `--cui-progress-segments` instead and the track holds exactly that many, scaled to its width, with the gap staying put. + + +
+ +
+
+
+
+
+
+
+
+
`} /> + +The bars above carry no `width`. The Progress plugin picks up every `.progress-segmented` on page load, reads `aria-valuenow`, `aria-valuemin` and `aria-valuemax` from the bar, and sets `--cui-progress-segments-filled` to the number of whole segments the value covers, rounded down; the stylesheet turns that into the bar's width, so the fill always ends on a segment's edge. Change `aria-valuenow` and the bar follows. The plugin also watches the track's width and writes the count that fits above `--cui-progress-segment-min-width` to `--cui-progress-segments-fit`, capped at `--cui-progress-segments` when that is set, and recounts the fill — so a narrow screen shows fewer segments rather than thinner ones, and the last segment is always whole. Without the plugin and without a count, the stylesheet tiles the minimum width and clips whatever is left at the end of the track. Without the plugin, set `--cui-progress-segments-filled` on the bar yourself, or give the bar a `width` as usual and accept a partly coloured segment on the boundary. + + +
+ +
+
+
`} /> + +The corners follow `--cui-progress-segment-border-radius`, `--cui-radius-2` by default — a full cap on a segment twice as wide as that, and the same corner on a wider one. The plugin caps it at half the segment and half the track height, as `border-radius` would; the stylesheet alone caps it at half the minimum width. Set it to `0px` for square segments, or anything in between. The value feeds the mask's `calc()`, so it has to be a length — a unitless `0` drops the whole mask. + + +
+ +
+
+
`} /> + ## On a colored surface Add `.progress-white` to draw the bar in white and the track as translucent white. That way a progress bar sitting on a dark or brand-colored surface stays readable without a color of its own; the bar stays white even under a `.theme-*` class. @@ -222,6 +278,38 @@ Or stack a header above the bar, with the value pushed to the end by `.ms-auto`: `} /> +## Usage + + + +The plugin only runs on `.progress-segmented`; a plain `.progress` is CSS only and never gets an instance. + +### Via data attributes + +Every `.progress-segmented` is initialized on page load — no toggle attribute needed. The bar's `aria-valuenow`, `aria-valuemin` and `aria-valuemax` are the value; leave `width` off the bar. `--cui-progress-segment-min-width` sets how many segments fit, and `--cui-progress-segments`, when set, caps that; both are read from the stylesheet, so set them on the element or on a parent. + +```html +
+
+
+``` + +### Via JavaScript + +```js +const element = document.querySelector('.progress-segmented') +const progress = new coreui.Progress(element) +``` + +### Methods + +| Method | Description | +| --- | --- | +| `update` | Recomputes the segment count and the filled segments. The plugin already watches the bar's `aria-*` attributes and the track's width, so this is for a change of the tokens made from script. | +| `dispose` | Destroys the instance and removes the variables it set. | +| `getInstance` | Static method which allows you to get the Progress instance associated to a DOM element, you can use it like this: `coreui.Progress.getInstance(element)` | +| `getOrCreateInstance` | Static method which returns a Progress instance associated to a DOM element or create a new one in case it wasn't initialized. You can use it like this: `coreui.Progress.getOrCreateInstance(element)` | + ## Customizing ### CSS variables @@ -230,6 +318,10 @@ Progress bars use local CSS variables on `.progress` for enhanced real-time cust +`.progress-segmented` adds the segment geometry: + + + ### Sass variables diff --git a/docs/src/content/docs/migration/v6.mdx b/docs/src/content/docs/migration/v6.mdx index b4005aea7..92e0f8fef 100644 --- a/docs/src/content/docs/migration/v6.mdx +++ b/docs/src/content/docs/migration/v6.mdx @@ -2133,6 +2133,30 @@ Multi Select's option indicators moved with it — they render as a decorative `.theme-*` class, as the variant is meant for dark and brand-coloured surfaces. +- **Gradient bars.** `--cui-progress-bar-bg-image` (default `none`) paints the + bar with an image on top of its colour, so a gradient is one variable on the + `.progress` or the bar. The stripes moved to their own tokens for the same + reason: `--cui-progress-bar-stripes` holds the pattern and + `--cui-progress-bar-stripe-color` its colour, and `.progress-bar-striped` + layers the pattern over the image. The `progress-bar-stripes` keyframes now + shift only the first background layer, so the image underneath stays put. + +- **`.progress-segmented`.** A mask that cuts a `.progress` or + `.progress-stacked` into a row of rounded segments, `--cui-progress-segment-min-width` + wide and `--cui-progress-segment-gap` apart — or exactly `--cui-progress-segments` + of them when that is set — with corners from + `--cui-progress-segment-border-radius` (`--cui-radius-2` by default, `0px` + for square). Everything under the mask — transition, theme, + gradient, stripes — behaves as before. + +- **New `Progress` plugin.** Initialized on every `.progress-segmented`, it + reads the bar's `aria-valuenow`, `aria-valuemin` and `aria-valuemax` and sets + `--cui-progress-segments-filled`, which the stylesheet turns into the bar's + width, so the fill ends on a whole segment. It also counts how many segments + of `--cui-progress-segment-min-width` fit the track, capped at + `--cui-progress-segments` when set, and follows the track as it resizes. Bars that keep an inline `width` are not touched. + `coreui.Progress` is exported next to the other plugins. + #### Spinner - Breaking diff --git a/js/index.umd.js b/js/index.umd.js index cb3231cc2..3df793acd 100644 --- a/js/index.umd.js +++ b/js/index.umd.js @@ -34,6 +34,7 @@ import NumberInput from './src/number-input.js' import PasswordInput from './src/password-input.js' import PasswordStrength from './src/password-strength.js' import Popover from './src/popover.js' +import Progress from './src/progress.js' import Range from './src/range.js' import RangeSlider from './src/range-slider.js' import Rating from './src/rating.js' @@ -77,6 +78,7 @@ export default { PasswordInput, PasswordStrength, Popover, + Progress, Range, RangeSlider, Rating, diff --git a/js/src/index.ts b/js/src/index.ts index ead0364df..e8d3d5d35 100644 --- a/js/src/index.ts +++ b/js/src/index.ts @@ -34,6 +34,7 @@ export { default as OTPInput } from './otp-input.js' export { default as PasswordInput } from './password-input.js' export { default as PasswordStrength } from './password-strength.js' export { default as Popover } from './popover.js' +export { default as Progress } from './progress.js' export { default as Range } from './range.js' export { default as RangeSlider } from './range-slider.js' export { default as Rating } from './rating.js' diff --git a/js/src/progress.ts b/js/src/progress.ts new file mode 100644 index 000000000..2c2872ca5 --- /dev/null +++ b/js/src/progress.ts @@ -0,0 +1,203 @@ +/** + * -------------------------------------------------------------------------- + * CoreUI PRO progress.ts + * License (https://coreui.io/pro/license/) + * -------------------------------------------------------------------------- + */ + +import BaseComponent from './base-component.js' +import EventHandler from './dom/event-handler.js' +import SelectorEngine from './dom/selector-engine.js' +import type { ComponentConfig } from './util/config.js' +import { defineJQueryPlugin } from './util/index.js' + +/** + * Constants + */ + +const NAME = 'progress' +const DATA_KEY = 'coreui.progress' +const EVENT_KEY = `.${DATA_KEY}` +const DATA_API_KEY = '.data-api' + +const EVENT_DOM_CONTENT_LOADED = `DOMContentLoaded${EVENT_KEY}${DATA_API_KEY}` + +const SELECTOR_PROGRESS = '.progress-segmented' +const SELECTOR_BAR = '.progress-bar' + +const ATTRIBUTE_VALUE_NOW = 'aria-valuenow' +const ATTRIBUTE_VALUE_MIN = 'aria-valuemin' +const ATTRIBUTE_VALUE_MAX = 'aria-valuemax' + +const PROPERTY_SEGMENTS = '--cui-progress-segments' +const PROPERTY_SEGMENTS_FIT = '--cui-progress-segments-fit' +const PROPERTY_SEGMENT_RADIUS_FIT = '--cui-progress-segment-radius-fit' +const PROPERTY_SEGMENTS_FILLED = '--cui-progress-segments-filled' +const PROPERTY_SEGMENT_GAP = '--cui-progress-segment-gap' +const PROPERTY_SEGMENT_MIN_WIDTH = '--cui-progress-segment-min-width' +const PROPERTY_SEGMENT_BORDER_RADIUS = '--cui-progress-segment-border-radius' + +/** + * Class definition + */ + +class Progress extends BaseComponent { + protected declare _bar: HTMLElement | null + protected declare _observer: MutationObserver | null + protected declare _resizeObserver: ResizeObserver | null + + constructor(element?: string | Element | null, config?: ComponentConfig | null) { + super(element, config) + + if (!this._element) { + return + } + + this._bar = SelectorEngine.children(this._element, SELECTOR_BAR)[0] ?? null + + if (!this._bar) { + return + } + + this._observer = new MutationObserver(() => this._update()) + this._observer.observe(this._bar, { attributeFilter: [ATTRIBUTE_VALUE_NOW, ATTRIBUTE_VALUE_MIN, ATTRIBUTE_VALUE_MAX] }) + this._resizeObserver = new ResizeObserver(() => this._update()) + this._resizeObserver.observe(this._element) + this._update() + } + + // Getters + static override get NAME(): string { + return NAME + } + + // Public + update(): void { + this._update() + } + + override dispose(): void { + this._observer?.disconnect() + this._resizeObserver?.disconnect() + this._element.style.removeProperty(PROPERTY_SEGMENTS_FIT) + this._element.style.removeProperty(PROPERTY_SEGMENT_RADIUS_FIT) + this._bar?.style.removeProperty(PROPERTY_SEGMENTS_FILLED) + + super.dispose() + } + + // Private + protected _attribute(name: string, fallback: number): number { + const value = Number.parseFloat(this._bar!.getAttribute(name) ?? '') + return Number.isNaN(value) ? fallback : value + } + + protected _ratio(): number { + const min = this._attribute(ATTRIBUTE_VALUE_MIN, 0) + const max = this._attribute(ATTRIBUTE_VALUE_MAX, 100) + const span = max - min + return span > 0 ? Math.min(Math.max((this._attribute(ATTRIBUTE_VALUE_NOW, min) - min) / span, 0), 1) : 0 + } + + protected _length(value: string): number { + const number = Number.parseFloat(value) + + if (Number.isNaN(number)) { + return 0 + } + + if (value.endsWith('rem')) { + return number * Number.parseFloat(getComputedStyle(document.documentElement).fontSize) + } + + if (value.endsWith('em')) { + return number * Number.parseFloat(getComputedStyle(this._element).fontSize) + } + + return number + } + + // The token is the ceiling; the track is cut into fewer segments when it is too narrow for them + protected _segmentsThatFit(): number { + const style = getComputedStyle(this._element) + const segments = Number.parseInt(style.getPropertyValue(PROPERTY_SEGMENTS), 10) || Number.POSITIVE_INFINITY + const gap = this._length(style.getPropertyValue(PROPERTY_SEGMENT_GAP)) + const minWidth = this._length(style.getPropertyValue(PROPERTY_SEGMENT_MIN_WIDTH)) + const { width } = this._element.getBoundingClientRect() + + if (minWidth <= 0 || width <= 0) { + return segments + } + + return Math.max(1, Math.min(segments, Math.floor((width + gap) / (minWidth + gap)))) + } + + // Half the segment, half the track height, or the token, whichever is smallest — as `border-radius` would + protected _radiusThatFits(segments: number): number { + const style = getComputedStyle(this._element) + const gap = this._length(style.getPropertyValue(PROPERTY_SEGMENT_GAP)) + const radius = this._length(style.getPropertyValue(PROPERTY_SEGMENT_BORDER_RADIUS)) + const { width, height } = this._element.getBoundingClientRect() + const segmentWidth = ((width + gap) / segments) - gap + + return Math.round(Math.max(0, Math.min(radius, segmentWidth / 2, height / 2)) * 1000) / 1000 + } + + protected _update(): void { + const segments = this._segmentsThatFit() + + // Nothing to count against: no token and no minimum width, so the stylesheet's fallback tiles apply + if (!Number.isFinite(segments)) { + this._element.style.removeProperty(PROPERTY_SEGMENTS_FIT) + this._element.style.removeProperty(PROPERTY_SEGMENT_RADIUS_FIT) + this._bar!.style.removeProperty(PROPERTY_SEGMENTS_FILLED) + return + } + + this._element.style.setProperty(PROPERTY_SEGMENTS_FIT, `${segments}`) + this._element.style.setProperty(PROPERTY_SEGMENT_RADIUS_FIT, `${this._radiusThatFits(segments)}px`) + + // No value on the bar means the page sets the fill itself, so leave its variable alone + if (!this._bar!.hasAttribute(ATTRIBUTE_VALUE_NOW)) { + return + } + + // Floor, so the bar never reads as complete before the value is + this._bar!.style.setProperty(PROPERTY_SEGMENTS_FILLED, `${Math.floor(this._ratio() * segments)}`) + } + + // Static + static jQueryInterface(this: any, config: any): void { + return this.each(function (this: HTMLElement) { + const data: any = Progress.getOrCreateInstance(this, config) + + if (typeof config !== 'string') { + return + } + + if (data[config as string] === undefined || config.startsWith('_') || config === 'constructor') { + throw new TypeError(`No method named "${config}"`) + } + + data[config as string](this) + }) + } +} + +/** + * Data API implementation + */ + +EventHandler.on(document, EVENT_DOM_CONTENT_LOADED, () => { + for (const element of SelectorEngine.find(SELECTOR_PROGRESS)) { + Progress.getOrCreateInstance(element) + } +}) + +/** + * jQuery + */ + +defineJQueryPlugin(Progress) + +export default Progress diff --git a/js/tests/unit/progress.spec.js b/js/tests/unit/progress.spec.js new file mode 100644 index 000000000..916ba1986 --- /dev/null +++ b/js/tests/unit/progress.spec.js @@ -0,0 +1,301 @@ +import Progress from '../../src/progress.js' +import { clearFixture, getFixture, jQueryMock } from '../helpers/fixture.js' + +describe('Progress', () => { + let fixtureEl + + beforeAll(() => { + fixtureEl = getFixture() + }) + + afterEach(() => { + clearFixture() + }) + + // The segment tokens come from the stylesheet, which the unit specs do not load + const getProgressHtml = (barAttributes = 'aria-valuenow="45" aria-valuemin="0" aria-valuemax="100"', wrapperStyle = '--cui-progress-segments: 40') => { + return ` +
+
+
+ ` + } + + const filled = element => element.querySelector('.progress-bar').style.getPropertyValue('--cui-progress-segments-filled') + const segments = element => element.style.getPropertyValue('--cui-progress-segments-fit') + const radius = element => element.style.getPropertyValue('--cui-progress-segment-radius-fit') + const settle = () => new Promise(resolve => { + setTimeout(resolve, 50) + }) + + describe('VERSION', () => { + it('should return plugin version', () => { + expect(Progress.VERSION).toEqual(jasmine.any(String)) + }) + }) + + describe('DATA_KEY', () => { + it('should return plugin data key', () => { + expect(Progress.DATA_KEY).toEqual('coreui.progress') + }) + }) + + describe('constructor', () => { + it('should take care of element either passed as a CSS selector or DOM element', () => { + fixtureEl.innerHTML = getProgressHtml() + + const progressEl = fixtureEl.querySelector('.progress') + const progressBySelector = new Progress('.progress') + expect(progressBySelector._element).toEqual(progressEl) + + progressBySelector.dispose() + + const progressByElement = new Progress(progressEl) + expect(progressByElement._element).toEqual(progressEl) + }) + + it('should write the whole segments the value covers, rounded down', () => { + fixtureEl.innerHTML = getProgressHtml('aria-valuenow="46" aria-valuemin="0" aria-valuemax="100"') + + const progressEl = fixtureEl.querySelector('.progress') + // eslint-disable-next-line no-new + new Progress(progressEl) + + expect(filled(progressEl)).toEqual('18') + }) + + it('should read the segment count from the CSS variable', () => { + fixtureEl.innerHTML = getProgressHtml('aria-valuenow="99" aria-valuemin="0" aria-valuemax="100"', '--cui-progress-segments: 10') + + const progressEl = fixtureEl.querySelector('.progress') + // eslint-disable-next-line no-new + new Progress(progressEl) + + expect(segments(progressEl)).toEqual('10') + expect(filled(progressEl)).toEqual('9') + }) + + it('should lower the count to what fits above the minimum segment width', () => { + fixtureEl.innerHTML = getProgressHtml('aria-valuenow="50" aria-valuemin="0" aria-valuemax="100"', '--cui-progress-segments: 40; width: 100px') + + const progressEl = fixtureEl.querySelector('.progress') + // eslint-disable-next-line no-new + new Progress(progressEl) + + expect(segments(progressEl)).toEqual('8') + expect(filled(progressEl)).toEqual('4') + }) + + it('should follow the track width', async () => { + fixtureEl.innerHTML = getProgressHtml('aria-valuenow="50" aria-valuemin="0" aria-valuemax="100"', '--cui-progress-segments: 40; width: 100px') + + const progressEl = fixtureEl.querySelector('.progress') + // eslint-disable-next-line no-new + new Progress(progressEl) + + progressEl.style.width = '200px' + await settle() + + expect(segments(progressEl)).toEqual('17') + expect(filled(progressEl)).toEqual('8') + + progressEl.style.width = '1000px' + await settle() + + expect(segments(progressEl)).toEqual('40') + expect(filled(progressEl)).toEqual('20') + }) + + it('should pack as many segments as fit when the count is unset', () => { + fixtureEl.innerHTML = getProgressHtml('aria-valuenow="50" aria-valuemin="0" aria-valuemax="100"', 'width: 250px') + + const progressEl = fixtureEl.querySelector('.progress') + // eslint-disable-next-line no-new + new Progress(progressEl) + + expect(segments(progressEl)).toEqual('21') + expect(filled(progressEl)).toEqual('10') + }) + + it('should leave the variables to the stylesheet without a count or a minimum width', () => { + fixtureEl.innerHTML = getProgressHtml('aria-valuenow="50" aria-valuemin="0" aria-valuemax="100"', '--cui-progress-segment-min-width: 0px') + + const progressEl = fixtureEl.querySelector('.progress') + // eslint-disable-next-line no-new + new Progress(progressEl) + + expect(segments(progressEl)).toEqual('') + expect(filled(progressEl)).toEqual('') + }) + + it('should cap the corner radius at half the segment and half the height', () => { + fixtureEl.innerHTML = getProgressHtml('aria-valuenow="50" aria-valuemin="0" aria-valuemax="100"', '--cui-progress-segments: 40; width: 100px; --cui-progress-segment-border-radius: 5px') + + const progressEl = fixtureEl.querySelector('.progress') + const progress = new Progress(progressEl) + + expect(radius(progressEl)).toEqual('3.625px') + + progressEl.style.width = '1000px' + progress.update() + expect(radius(progressEl)).toEqual('5px') + + progressEl.style.height = '4px' + progress.update() + expect(radius(progressEl)).toEqual('2px') + }) + + it('should keep the count when the minimum width is unset', () => { + fixtureEl.innerHTML = getProgressHtml('aria-valuenow="99" aria-valuemin="0" aria-valuemax="100"', '--cui-progress-segments: 10; --cui-progress-segment-min-width: 0px; width: 20px') + + const progressEl = fixtureEl.querySelector('.progress') + // eslint-disable-next-line no-new + new Progress(progressEl) + + expect(filled(progressEl)).toEqual('9') + }) + + it('should scale the value against aria-valuemin and aria-valuemax', () => { + fixtureEl.innerHTML = getProgressHtml('aria-valuenow="15" aria-valuemin="10" aria-valuemax="20"') + + const progressEl = fixtureEl.querySelector('.progress') + // eslint-disable-next-line no-new + new Progress(progressEl) + + expect(filled(progressEl)).toEqual('20') + }) + + it('should default min and max to 0 and 100 and clamp the value', () => { + fixtureEl.innerHTML = getProgressHtml('aria-valuenow="130"') + + const progressEl = fixtureEl.querySelector('.progress') + // eslint-disable-next-line no-new + new Progress(progressEl) + + expect(filled(progressEl)).toEqual('40') + }) + + it('should leave a bar without aria-valuenow alone', () => { + fixtureEl.innerHTML = getProgressHtml('style="--cui-progress-segments-filled: 5"') + + const progressEl = fixtureEl.querySelector('.progress') + // eslint-disable-next-line no-new + new Progress(progressEl) + + expect(filled(progressEl)).toEqual('5') + }) + + it('should not throw without a bar', () => { + fixtureEl.innerHTML = '
' + + const progressEl = fixtureEl.querySelector('.progress') + + expect(() => new Progress(progressEl)).not.toThrow() + }) + }) + + describe('update', () => { + it('should follow aria-valuenow changes on its own', async () => { + fixtureEl.innerHTML = getProgressHtml() + + const progressEl = fixtureEl.querySelector('.progress') + // eslint-disable-next-line no-new + new Progress(progressEl) + + progressEl.querySelector('.progress-bar').setAttribute('aria-valuenow', '75') + await new Promise(resolve => { + setTimeout(resolve, 0) + }) + + expect(filled(progressEl)).toEqual('30') + }) + + it('should recompute on demand', () => { + fixtureEl.innerHTML = getProgressHtml() + + const progressEl = fixtureEl.querySelector('.progress') + const progress = new Progress(progressEl) + + progressEl.style.setProperty('--cui-progress-segments', '20') + progress.update() + + expect(filled(progressEl)).toEqual('9') + }) + }) + + describe('dispose', () => { + it('should stop observing and remove the variables', async () => { + fixtureEl.innerHTML = getProgressHtml() + + const progressEl = fixtureEl.querySelector('.progress') + const progress = new Progress(progressEl) + + progress.dispose() + + expect(filled(progressEl)).toEqual('') + expect(segments(progressEl)).toEqual('') + expect(radius(progressEl)).toEqual('') + expect(Progress.getInstance(progressEl)).toBeNull() + + progressEl.querySelector('.progress-bar').setAttribute('aria-valuenow', '75') + await new Promise(resolve => { + setTimeout(resolve, 0) + }) + + expect(filled(progressEl)).toEqual('') + }) + }) + + describe('data-api', () => { + it('should initialize every segmented progress on DOMContentLoaded', () => { + fixtureEl.innerHTML = [ + getProgressHtml(), + '
' + ].join('') + + document.dispatchEvent(new Event('DOMContentLoaded')) + + expect(Progress.getInstance(fixtureEl.querySelector('.progress-segmented'))).toBeInstanceOf(Progress) + expect(Progress.getInstance(fixtureEl.querySelector('#plain'))).toBeNull() + }) + }) + + describe('jQueryInterface', () => { + it('should create a progress via jQueryInterface', () => { + fixtureEl.innerHTML = getProgressHtml() + const progressEl = fixtureEl.querySelector('.progress') + + jQueryMock.fn.progress = Progress.jQueryInterface + jQueryMock.elements = [progressEl] + jQueryMock.fn.progress.call(jQueryMock) + + expect(Progress.getInstance(progressEl)).not.toBeNull() + }) + + it('should call a public method by name', () => { + fixtureEl.innerHTML = getProgressHtml() + const progressEl = fixtureEl.querySelector('.progress') + + jQueryMock.fn.progress = Progress.jQueryInterface + jQueryMock.elements = [progressEl] + jQueryMock.fn.progress.call(jQueryMock) + + progressEl.style.setProperty('--cui-progress-segments', '20') + jQueryMock.fn.progress.call(jQueryMock, 'update') + + expect(filled(progressEl)).toEqual('9') + }) + + it('should throw error on undefined method', () => { + fixtureEl.innerHTML = getProgressHtml() + const progressEl = fixtureEl.querySelector('.progress') + + jQueryMock.fn.progress = Progress.jQueryInterface + jQueryMock.elements = [progressEl] + + expect(() => { + jQueryMock.fn.progress.call(jQueryMock, 'noMethod') + }).toThrowError(TypeError, 'No method named "noMethod"') + }) + }) +}) diff --git a/scss/_progress.scss b/scss/_progress.scss index 55b0bc886..786b4813b 100644 --- a/scss/_progress.scss +++ b/scss/_progress.scss @@ -1,7 +1,7 @@ +@use "sass:map"; @use "functions/defaults" as *; @use "mixins/border-radius" as *; @use "mixins/box-shadow" as *; -@use "mixins/gradients" as *; @use "mixins/tokens" as *; @use "mixins/transition" as *; @use "config" as *; @@ -14,9 +14,17 @@ $progress-border-radius: var(--#{$prefix}radius-5) !default; $progress-box-shadow: var(--#{$prefix}box-shadow-inset) !default; $progress-bar-color: var(--#{$prefix}primary-contrast) !default; $progress-bar-bg: var(--#{$prefix}primary-bg) !default; +$progress-bar-bg-image: none !default; +$progress-bar-stripes: linear-gradient(45deg, var(--#{$prefix}progress-bar-stripe-color) 25%, transparent 25%, transparent 50%, var(--#{$prefix}progress-bar-stripe-color) 50%, var(--#{$prefix}progress-bar-stripe-color) 75%, transparent 75%, transparent) !default; +$progress-bar-stripe-color: color-mix(in oklch, var(--#{$prefix}white) 15%, transparent) !default; $progress-bar-transition: width .6s ease !default; $progress-bar-animation: progress-bar-stripes 1s linear infinite !default; +$progress-segments: null !default; +$progress-segment-gap: .25rem !default; +$progress-segment-min-width: .25rem !default; +$progress-segment-border-radius: var(--#{$prefix}radius-2) !default; + $progress-contrast-track-opacity: 20% !default; // scss-docs-end progress-variables @@ -34,6 +42,9 @@ $progress-tokens: defaults( --#{$prefix}progress-box-shadow: #{$progress-box-shadow}, --#{$prefix}progress-bar-color: #{$progress-bar-color}, --#{$prefix}progress-bar-bg: #{$progress-bar-bg}, + --#{$prefix}progress-bar-bg-image: #{$progress-bar-bg-image}, + --#{$prefix}progress-bar-stripes: #{$progress-bar-stripes}, + --#{$prefix}progress-bar-stripe-color: #{$progress-bar-stripe-color}, --#{$prefix}progress-bar-transition: #{$progress-bar-transition}, --#{$prefix}progress-bar-animation: #{$progress-bar-animation}, ), @@ -41,6 +52,24 @@ $progress-tokens: defaults( ); // scss-docs-end progress-css-vars +$progress-segmented-tokens: () !default; + +// scss-docs-start progress-segmented-css-vars +$progress-segmented-tokens: defaults( + ( + --#{$prefix}progress-segment-gap: #{$progress-segment-gap}, + --#{$prefix}progress-segment-min-width: #{$progress-segment-min-width}, + --#{$prefix}progress-segment-border-radius: #{$progress-segment-border-radius}, + ), + $progress-segmented-tokens +); + +// Unset by default: the plugin then packs as many segments as fit above the minimum width +@if $progress-segments != null { + $progress-segmented-tokens: map.set($progress-segmented-tokens, --#{$prefix}progress-segments, $progress-segments); +} +// scss-docs-end progress-segmented-css-vars + // stylelint-enable custom-property-no-missing-var-function, scss/dollar-variable-default @layer components { // Disable animation if transitions are disabled @@ -48,7 +77,7 @@ $progress-tokens: defaults( // scss-docs-start progress-keyframes @if $enable-transitions { @keyframes progress-bar-stripes { - 0% { background-position-x: var(--#{$prefix}progress-height); } + 0% { background-position-x: var(--#{$prefix}progress-height), 0; } } } // scss-docs-end progress-keyframes @@ -75,12 +104,13 @@ $progress-tokens: defaults( text-align: center; white-space: nowrap; background-color: var(--#{$prefix}theme-bg, var(--#{$prefix}progress-bar-bg)); + background-image: var(--#{$prefix}progress-bar-bg-image); @include transition(var(--#{$prefix}progress-bar-transition)); } .progress-bar-striped { - @include gradient-striped(); - background-size: var(--#{$prefix}progress-height) var(--#{$prefix}progress-height); + background-image: var(--#{$prefix}progress-bar-stripes), var(--#{$prefix}progress-bar-bg-image); + background-size: var(--#{$prefix}progress-height) var(--#{$prefix}progress-height), auto; } .progress-stacked > .progress { @@ -103,6 +133,41 @@ $progress-tokens: defaults( } } + .progress-segmented { + @include tokens($progress-segmented-tokens); + + $gap: var(--#{$prefix}progress-segment-gap); + // Capped at half the segment so the corner circles never cross; the plugin writes the exact cap, + // the stylesheet's fallback knows only the minimum width + $radius: var(--#{$prefix}progress-segment-radius-fit, min(var(--#{$prefix}progress-segment-border-radius), calc(var(--#{$prefix}progress-segment-min-width) * .5))); + // Without a count (neither the token nor the plugin's fit) the pitch is invalid and the + // tiles fall back to the minimum width, so a page without JavaScript still gets segments + $pitch: var(--#{$prefix}progress-segment-pitch, calc(var(--#{$prefix}progress-segment-min-width) + #{$gap})); + + --#{$prefix}progress-border-radius: 0; + --#{$prefix}progress-box-shadow: none; + --#{$prefix}progress-segment-pitch: calc((100% + #{$gap}) / var(--#{$prefix}progress-segments-fit, var(--#{$prefix}progress-segments))); + + // One tile per segment: a rounded rectangle from two crossed bars plus a circle in each corner, + // intersected with the segment's box so a corner wider than the segment is cut off at the gap + // instead of spilling into it. The trailing gap of the last tile is what the element's edge clips. + mask-image: linear-gradient(to right, #000 calc(100% - #{$gap}), transparent calc(100% - #{$gap})), linear-gradient(#000, #000), linear-gradient(to right, transparent #{$radius}, #000 #{$radius}, #000 calc(100% - #{$gap} - #{$radius}), transparent calc(100% - #{$gap} - #{$radius})), radial-gradient(circle #{$radius} at #{$radius} #{$radius}, #000 100%, transparent), radial-gradient(circle #{$radius} at calc(100% - #{$gap} - #{$radius}) #{$radius}, #000 100%, transparent), radial-gradient(circle #{$radius} at #{$radius} calc(100% - #{$radius}), #000 100%, transparent), radial-gradient(circle #{$radius} at calc(100% - #{$gap} - #{$radius}) calc(100% - #{$radius}), #000 100%, transparent); + mask-repeat: repeat no-repeat; + mask-position: 0 0, 0 50%, 0 0, 0 0, 0 0, 0 0, 0 0; + mask-size: #{$pitch} 100%, #{$pitch} calc(100% - #{$radius} * 2), #{$pitch} 100%, #{$pitch} 100%, #{$pitch} 100%, #{$pitch} 100%, #{$pitch} 100%; + mask-composite: intersect, add, add, add, add, add, add; + + > .progress { + --#{$prefix}progress-border-radius: 0; + } + + // Whole segments: the Progress plugin (or the page) sets the count to fill, and the bar + // ends on that segment's far edge instead of wherever the value lands. + > .progress-bar { + width: calc(var(--#{$prefix}progress-segments-filled) * #{$pitch} - #{$gap}); + } + } + .progress-white { --#{$prefix}progress-bg: #{color-mix(in oklch, var(--#{$prefix}white) $progress-contrast-track-opacity, transparent)}; --#{$prefix}progress-bar-bg: var(--#{$prefix}white); diff --git a/scss/tests/_progress.test.scss b/scss/tests/_progress.test.scss new file mode 100644 index 000000000..7404ba137 --- /dev/null +++ b/scss/tests/_progress.test.scss @@ -0,0 +1,19 @@ +@use "sass:map"; +@use "../progress" as *; + +@include describe("progress") { + @include it("exposes the bar image and stripe tokens with an empty image by default") { + @include assert-equal(map.get($progress-tokens, --cui-progress-bar-bg-image), none); + @include assert-true(map.has-key($progress-tokens, --cui-progress-bar-stripes)); + @include assert-true(map.has-key($progress-tokens, --cui-progress-bar-stripe-color)); + } + + @include it("keeps the segment geometry in its own map, read only by .progress-segmented") { + @include assert-false(map.has-key($progress-segmented-tokens, --cui-progress-segments)); + @include assert-true(map.has-key($progress-segmented-tokens, --cui-progress-segment-gap)); + @include assert-true(map.has-key($progress-segmented-tokens, --cui-progress-segment-min-width)); + @include assert-true(map.has-key($progress-segmented-tokens, --cui-progress-segment-border-radius)); + @include assert-false(map.has-key($progress-tokens, --cui-progress-segments)); + } + +}