-
Notifications
You must be signed in to change notification settings - Fork 2.8k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
[web-components] refactor color recipes for DI (#18199)
* add color-vNext folder with recipes and update specs * Change files
- Loading branch information
1 parent
aa843ee
commit 84a7f4d
Showing
46 changed files
with
1,949 additions
and
437 deletions.
There are no files selected for viewing
7 changes: 7 additions & 0 deletions
7
change/@fluentui-web-components-91e2200b-d44a-48fe-9075-c23eced8be5f.json
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
{ | ||
"type": "minor", | ||
"comment": "add color-vNext folder with recipes and update specs", | ||
"packageName": "@fluentui/web-components", | ||
"email": "[email protected]", | ||
"dependentChangeType": "patch" | ||
} |
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,32 @@ | ||
# Fluent Color Recipes | ||
|
||
Color recipes are named colors who's value is algorithmically defined from a variety of inputs. `@fluentui/web-components` relies on these recipes heavily to achieve expressive theming options while maintaining color accessability targets. | ||
|
||
## Swatch | ||
|
||
A Swatch is a representation of a color that has a `relativeLuminance` value and a method to convert the swatch to a color string. It is used by recipes to determine which colors to use for UI. | ||
|
||
### SwatchRGB | ||
|
||
A concrete implementation of `Swatch`, it is a swatch with red, green, and blue 64bit color channels . | ||
|
||
**Example: Creating a SwatchRGB** | ||
|
||
```ts | ||
import { SwatchRGB } from '@fluentui/web-components'; | ||
|
||
const red = SwatchRGB.create(1, 0, 0); | ||
``` | ||
|
||
## Palette | ||
|
||
A palette is a collection `Swatch` instances, ordered by relative luminance, and provides mechanisms to safely retrieve swatches by index and by target contrast ratios. It also contains a `source` color, which is the color from which the palette is | ||
|
||
### PaletteRGB | ||
|
||
An implementation of `Palette` of `SwatchRGB` instances. | ||
|
||
```ts | ||
// Create a palette from the red swatch | ||
const palette = PaletteRGB.create(red): | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,143 @@ | ||
import { clamp, ColorRGBA64, ComponentStateColorPalette, parseColorHexRGB } from '@microsoft/fast-colors'; | ||
import { Swatch, SwatchRGB } from './swatch'; | ||
import { binarySearch } from './utilities/binary-search'; | ||
import { directionByIsDark } from './utilities/direction-by-is-dark'; | ||
import { contrast, RelativeLuminance } from './utilities/relative-luminance'; | ||
|
||
/** | ||
* A collection of {@link Swatch} instances | ||
* @public | ||
*/ | ||
export interface Palette<T extends Swatch = Swatch> { | ||
readonly source: T; | ||
readonly swatches: ReadonlyArray<T>; | ||
|
||
/** | ||
* Returns a swatch from the palette that most closely matches | ||
* the contrast ratio provided to a provided reference. | ||
*/ | ||
colorContrast(reference: Swatch, contrast: number, initialIndex?: number, direction?: 1 | -1): T; | ||
|
||
/** | ||
* Returns the index of the palette that most closely matches | ||
* the relativeLuminance of the provided swatch | ||
*/ | ||
closestIndexOf(reference: RelativeLuminance): number; | ||
|
||
/** | ||
* Gets a swatch by index. Index is clamped to the limits | ||
* of the palette so a Swatch will always be returned. | ||
*/ | ||
get(index: number): T; | ||
} | ||
|
||
export type PaletteRGB = Palette<SwatchRGB>; | ||
|
||
export const PaletteRGB = Object.freeze({ | ||
create(source: SwatchRGB): PaletteRGB { | ||
return PaletteRGBImpl.from(source); | ||
}, | ||
}); | ||
|
||
/** | ||
* A {@link Palette} representing RGB swatch values. | ||
* @public | ||
*/ | ||
class PaletteRGBImpl implements Palette<SwatchRGB> { | ||
/** | ||
* {@inheritdoc Palette.source} | ||
*/ | ||
public readonly source: SwatchRGB; | ||
public readonly swatches: ReadonlyArray<SwatchRGB>; | ||
private lastIndex: number; | ||
private reversedSwatches: ReadonlyArray<SwatchRGB>; | ||
/** | ||
* | ||
* @param source - The source color for the palette | ||
* @param swatches - All swatches in the palette | ||
*/ | ||
constructor(source: SwatchRGB, swatches: ReadonlyArray<SwatchRGB>) { | ||
this.source = source; | ||
this.swatches = swatches; | ||
|
||
this.reversedSwatches = Object.freeze([...this.swatches].reverse()); | ||
this.lastIndex = this.swatches.length - 1; | ||
} | ||
|
||
/** | ||
* {@inheritdoc Palette.colorContrast} | ||
*/ | ||
public colorContrast( | ||
reference: Swatch, | ||
contrastTarget: number, | ||
initialSearchIndex?: number, | ||
direction?: 1 | -1, | ||
): SwatchRGB { | ||
if (initialSearchIndex === undefined) { | ||
initialSearchIndex = this.closestIndexOf(reference); | ||
} | ||
|
||
let source: ReadonlyArray<SwatchRGB> = this.swatches; | ||
const endSearchIndex = this.lastIndex; | ||
let startSearchIndex = initialSearchIndex; | ||
|
||
if (direction === undefined) { | ||
direction = directionByIsDark(reference); | ||
} | ||
|
||
const condition = (value: SwatchRGB) => contrast(reference, value) >= contrastTarget; | ||
|
||
if (direction === -1) { | ||
source = this.reversedSwatches; | ||
startSearchIndex = endSearchIndex - startSearchIndex; | ||
} | ||
|
||
return binarySearch(source, condition, startSearchIndex, endSearchIndex); | ||
} | ||
|
||
/** | ||
* {@inheritdoc Palette.get} | ||
*/ | ||
public get(index: number): SwatchRGB { | ||
return this.swatches[index] || this.swatches[clamp(index, 0, this.lastIndex)]; | ||
} | ||
|
||
/** | ||
* {@inheritdoc Palette.closestIndexOf} | ||
*/ | ||
public closestIndexOf(reference: Swatch): number { | ||
const index = this.swatches.indexOf(reference as SwatchRGB); | ||
|
||
if (index !== -1) { | ||
return index; | ||
} | ||
|
||
const closest = this.swatches.reduce((previous, next) => | ||
Math.abs(next.relativeLuminance - reference.relativeLuminance) < | ||
Math.abs(previous.relativeLuminance - reference.relativeLuminance) | ||
? next | ||
: previous, | ||
); | ||
|
||
return this.swatches.indexOf(closest); | ||
} | ||
|
||
/** | ||
* Create a color palette from a provided swatch | ||
* @param source - The source swatch to create a palette from | ||
* @returns | ||
*/ | ||
static from(source: SwatchRGB): PaletteRGB { | ||
return new PaletteRGBImpl( | ||
source, | ||
Object.freeze( | ||
new ComponentStateColorPalette({ | ||
baseColor: ColorRGBA64.fromObject(source)!, | ||
}).palette.map(x => { | ||
const _x = parseColorHexRGB(x.toStringHexRGB())!; | ||
return SwatchRGB.create(_x.r, _x.g, _x.b); | ||
}), | ||
), | ||
); | ||
} | ||
} |
54 changes: 54 additions & 0 deletions
54
packages/web-components/src/color-vNext/recipes/accent-fill.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,54 @@ | ||
import { inRange } from 'lodash-es'; | ||
import { PaletteRGB } from '../palette'; | ||
import { Swatch } from '../swatch'; | ||
import { isDark } from '../utilities/is-dark'; | ||
|
||
/** | ||
* @internal | ||
*/ | ||
export function accentFill( | ||
palette: PaletteRGB, | ||
neutralPaletteRGB: PaletteRGB, | ||
reference: Swatch, | ||
textColor: Swatch, | ||
contrastTarget: number, | ||
hoverDelta: number, | ||
activeDelta: number, | ||
focusDelta: number, | ||
selectedDelta: number, | ||
neutralFillRestDelta: number, | ||
neutralFillHoverDelta: number, | ||
neutralFillActiveDelta: number, | ||
) { | ||
const accent = palette.source; | ||
const referenceIndex = neutralPaletteRGB.closestIndexOf(reference); | ||
const swapThreshold = Math.max(neutralFillRestDelta, neutralFillHoverDelta, neutralFillActiveDelta); | ||
const direction = referenceIndex >= swapThreshold ? -1 : 1; | ||
const paletteLength = palette.swatches.length; | ||
const maxIndex = paletteLength - 1; | ||
const accentIndex = palette.closestIndexOf(accent); | ||
let accessibleOffset = 0; | ||
|
||
while ( | ||
accessibleOffset < direction * hoverDelta && | ||
inRange(accentIndex + accessibleOffset + direction, 0, paletteLength) && | ||
textColor.contrast(palette.get(accentIndex + accessibleOffset + direction)) >= contrastTarget && | ||
inRange(accentIndex + accessibleOffset + direction + direction, 0, maxIndex) | ||
) { | ||
accessibleOffset += direction; | ||
} | ||
|
||
const hoverIndex = accentIndex + accessibleOffset; | ||
const restIndex = hoverIndex + direction * -1 * hoverDelta; | ||
const activeIndex = restIndex + direction * activeDelta; | ||
const focusIndex = restIndex + direction * focusDelta; | ||
const selectedIndex = restIndex + (isDark(reference) ? selectedDelta * -1 : selectedDelta); | ||
|
||
return { | ||
rest: palette.get(restIndex), | ||
hover: palette.get(hoverIndex), | ||
active: palette.get(activeIndex), | ||
focus: palette.get(focusIndex), | ||
selected: palette.get(selectedIndex), | ||
}; | ||
} |
9 changes: 9 additions & 0 deletions
9
packages/web-components/src/color-vNext/recipes/accent-foreground-cut.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
import { Swatch } from '../swatch'; | ||
import { black, white } from '../utilities/color-constants'; | ||
|
||
/** | ||
* @internal | ||
*/ | ||
export function accentForegroundCut(reference: Swatch, contrastTarget: number) { | ||
return reference.contrast(white) >= contrastTarget ? white : black; | ||
} |
45 changes: 45 additions & 0 deletions
45
packages/web-components/src/color-vNext/recipes/accent-foreground.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,45 @@ | ||
import { PaletteRGB } from '../palette'; | ||
import { Swatch } from '../swatch'; | ||
import { directionByIsDark } from '../utilities/direction-by-is-dark'; | ||
|
||
/** | ||
* @internal | ||
*/ | ||
export function accentForeground( | ||
palette: PaletteRGB, | ||
reference: Swatch, | ||
contrastTarget: number, | ||
restDelta: number, | ||
hoverDelta: number, | ||
activeDelta: number, | ||
focusDelta: number, | ||
) { | ||
const accent = palette.source; | ||
const accentIndex = palette.closestIndexOf(accent); | ||
const direction = directionByIsDark(reference); | ||
const startIndex = | ||
accentIndex + | ||
(direction === 1 ? Math.min(restDelta, hoverDelta) : Math.max(direction * restDelta, direction * hoverDelta)); | ||
const accessibleSwatch = palette.colorContrast(reference, contrastTarget, startIndex, direction); | ||
const accessibleIndex1 = palette.closestIndexOf(accessibleSwatch); | ||
const accessibleIndex2 = accessibleIndex1 + direction * Math.abs(restDelta - hoverDelta); | ||
const indexOneIsRestState = direction === 1 ? restDelta < hoverDelta : direction * restDelta > direction * hoverDelta; | ||
|
||
let restIndex: number; | ||
let hoverIndex: number; | ||
|
||
if (indexOneIsRestState) { | ||
restIndex = accessibleIndex1; | ||
hoverIndex = accessibleIndex2; | ||
} else { | ||
restIndex = accessibleIndex2; | ||
hoverIndex = accessibleIndex1; | ||
} | ||
|
||
return { | ||
rest: palette.get(restIndex), | ||
hover: palette.get(hoverIndex), | ||
active: palette.get(restIndex + direction * activeDelta), | ||
focus: palette.get(restIndex + direction * focusDelta), | ||
}; | ||
} |
15 changes: 15 additions & 0 deletions
15
packages/web-components/src/color-vNext/recipes/neutral-divider.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
import { Swatch } from '../swatch'; | ||
import { PaletteRGB } from '../palette'; | ||
import { directionByIsDark } from '../utilities/direction-by-is-dark'; | ||
|
||
/** | ||
* The neutralDivider color recipe | ||
* @param palette - The palette to operate on | ||
* @param reference - The reference color | ||
* @param delta - The offset from the reference | ||
* | ||
* @internal | ||
*/ | ||
export function neutralDivider(palette: PaletteRGB, reference: Swatch, delta: number) { | ||
return palette.get(palette.closestIndexOf(reference) + directionByIsDark(reference) * delta); | ||
} |
11 changes: 11 additions & 0 deletions
11
packages/web-components/src/color-vNext/recipes/neutral-fill-card.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
import { PaletteRGB } from '../palette'; | ||
import { Swatch } from '../swatch'; | ||
|
||
/** | ||
* @internal | ||
*/ | ||
export function neutralFillCard(palette: PaletteRGB, reference: Swatch, delta: number) { | ||
const referenceIndex = palette.closestIndexOf(reference); | ||
|
||
return palette.get(referenceIndex - (referenceIndex < delta ? delta * -1 : delta)); | ||
} |
27 changes: 27 additions & 0 deletions
27
packages/web-components/src/color-vNext/recipes/neutral-fill-input.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,27 @@ | ||
import { PaletteRGB } from '../palette'; | ||
import { Swatch } from '../swatch'; | ||
import { directionByIsDark } from '../utilities/direction-by-is-dark'; | ||
|
||
/** | ||
* @internal | ||
*/ | ||
export function neutralFillInput( | ||
palette: PaletteRGB, | ||
reference: Swatch, | ||
restDelta: number, | ||
hoverDelta: number, | ||
activeDelta: number, | ||
focusDelta: number, | ||
selectedDelta: number, | ||
) { | ||
const direction = directionByIsDark(reference); | ||
const referenceIndex = palette.closestIndexOf(reference); | ||
|
||
return { | ||
rest: palette.get(referenceIndex - direction * restDelta), | ||
hover: palette.get(referenceIndex - direction * hoverDelta), | ||
active: palette.get(referenceIndex - direction * activeDelta), | ||
focus: palette.get(referenceIndex - direction * focusDelta), | ||
selected: palette.get(referenceIndex - direction * selectedDelta), | ||
}; | ||
} |
41 changes: 41 additions & 0 deletions
41
packages/web-components/src/color-vNext/recipes/neutral-fill-stealth.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,41 @@ | ||
import { PaletteRGB } from '../palette'; | ||
import { Swatch } from '../swatch'; | ||
|
||
/** | ||
* @internal | ||
*/ | ||
export function neutralFillStealth( | ||
palette: PaletteRGB, | ||
reference: Swatch, | ||
restDelta: number, | ||
hoverDelta: number, | ||
activeDelta: number, | ||
focusDelta: number, | ||
selectedDelta: number, | ||
fillRestDelta: number, | ||
fillHoverDelta: number, | ||
fillActiveDelta: number, | ||
fillFocusDelta: number, | ||
) { | ||
const swapThreshold = Math.max( | ||
restDelta, | ||
hoverDelta, | ||
activeDelta, | ||
focusDelta, | ||
fillRestDelta, | ||
fillHoverDelta, | ||
fillActiveDelta, | ||
fillFocusDelta, | ||
); | ||
|
||
const referenceIndex = palette.closestIndexOf(reference); | ||
const direction: 1 | -1 = referenceIndex >= swapThreshold ? -1 : 1; | ||
|
||
return { | ||
rest: palette.get(referenceIndex + direction * restDelta), | ||
hover: palette.get(referenceIndex + direction * hoverDelta), | ||
active: palette.get(referenceIndex + direction * activeDelta), | ||
focus: palette.get(referenceIndex + direction * focusDelta), | ||
selected: palette.get(referenceIndex + direction * selectedDelta), | ||
}; | ||
} |
Oops, something went wrong.