Skip to content

Commit

Permalink
feat: refactor color recipes (#4623)
Browse files Browse the repository at this point in the history
* refactor color recipes away from DesignSystem data structure

* rename dir

* cleanup

* factor binary-search out to it's own file

* updating code docs

* Change files

* fixing binary-search

* Update packages/web-components/fast-components/src/color-vNext/palette.ts

Co-authored-by: Brian Heston <[email protected]>

* addressing feedback

* adding readme

* pretty pretty

closes #3833
Co-authored-by: nicholasrice <[email protected]>
Co-authored-by: Brian Heston <[email protected]>
  • Loading branch information
3 people authored and chrisdholt committed Jun 24, 2021
1 parent dbc4e48 commit d6d4bbc
Show file tree
Hide file tree
Showing 40 changed files with 1,060 additions and 7 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"type": "minor",
"packageName": "@microsoft/fast-components",
"email": "[email protected]",
"dependentChangeType": "patch"
}
28 changes: 28 additions & 0 deletions packages/web-components/fast-components/src/color-vNext/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# FAST Color Recipes

Color recipes are named colors who's value is algorithmically defined from a variety of inputs. `@microsoft/fast-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 "@microsoft/fast-components";

const red = new SwatchRGB(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.from(red):
```
145 changes: 145 additions & 0 deletions packages/web-components/fast-components/src/color-vNext/palette.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import {
clamp,
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
): Swatch;

/**
* 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;
}

/**
* A {@link Palette} representing RGB swatch values.
* @public
*/
export class PaletteRGB 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) {
return new PaletteRGB(
source,
Object.freeze(
new ComponentStateColorPalette({
baseColor: source,
}).palette.map(x => {
const _x = parseColorHexRGB(x.toStringHexRGB())!;
return new SwatchRGB(_x.r, _x.g, _x.b);
})
)
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { inRange } from "lodash";
import { Palette } from "../palette";
import { Swatch } from "../swatch";
import { isDark } from "../utilities/is-dark";

/**
* @internal
*/
export function accentFill(
palette: Palette,
neutralPalette: Palette,
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 = neutralPalette.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),
};
}
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;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { Palette } from "../palette";
import { Swatch } from "../swatch";
import { directionByIsDark } from "../utilities/direction-by-is-dark";

/**
* @internal
*/
export function accentForeground(
palette: Palette,
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),
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Swatch } from "../swatch";
import { Palette } 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: Palette, reference: Swatch, delta: number) {
return palette.get(
palette.closestIndexOf(reference) + directionByIsDark(reference) * delta
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Palette } from "../palette";
import { Swatch } from "../swatch";

/**
* @internal
*/
export function neutralFillCard(palette: Palette, reference: Swatch, delta: number) {
const referenceIndex = palette.closestIndexOf(reference);

return palette.get(referenceIndex - (referenceIndex < delta ? delta * -1 : delta));
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Palette } from "../palette";
import { Swatch } from "../swatch";
import { directionByIsDark } from "../utilities/direction-by-is-dark";

/**
* @internal
*/
export function neutralFillInput(
palette: Palette,
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),
};
}
Loading

0 comments on commit d6d4bbc

Please sign in to comment.