Skip to content

Commit

Permalink
[web-components] refactor color recipes for DI (microsoft#18199)
Browse files Browse the repository at this point in the history
* add color-vNext folder with recipes and update specs

* Change files
  • Loading branch information
chrisdholt authored and PeterDraex committed Aug 6, 2021
1 parent f601729 commit 8ecc9b4
Show file tree
Hide file tree
Showing 46 changed files with 1,949 additions and 437 deletions.
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"
}
862 changes: 621 additions & 241 deletions packages/web-components/docs/api-report.md

Large diffs are not rendered by default.

32 changes: 32 additions & 0 deletions packages/web-components/src/color-vNext/README.md
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):
```
143 changes: 143 additions & 0 deletions packages/web-components/src/color-vNext/palette.ts
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 packages/web-components/src/color-vNext/recipes/accent-fill.ts
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),
};
}
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,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 packages/web-components/src/color-vNext/recipes/neutral-divider.ts
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);
}
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));
}
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),
};
}
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),
};
}
Loading

0 comments on commit 8ecc9b4

Please sign in to comment.