Skip to content

Commit 6576be9

Browse files
nicholasricebheston
andcommitted
feat: refactor color recipes (#4623)
* 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]>
1 parent f772aa2 commit 6576be9

40 files changed

+1060
-7
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"type": "minor",
3+
"packageName": "@microsoft/fast-components",
4+
"email": "[email protected]",
5+
"dependentChangeType": "patch"
6+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# FAST Color Recipes
2+
3+
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.
4+
5+
6+
## Swatch
7+
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.
8+
9+
### SwatchRGB
10+
A concrete implementation of `Swatch`, it is a swatch with red, green, and blue 64bit color channels .
11+
12+
**Example: Creating a SwatchRGB**
13+
```ts
14+
import { SwatchRGB } from "@microsoft/fast-components";
15+
16+
const red = new SwatchRGB(1, 0, 0);
17+
```
18+
19+
## Palette
20+
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
21+
22+
### PaletteRGB
23+
An implementation of `Palette` of `SwatchRGB` instances.
24+
25+
```ts
26+
// Create a palette from the red swatch
27+
const palette = PaletteRGB.from(red):
28+
```
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import {
2+
clamp,
3+
ComponentStateColorPalette,
4+
parseColorHexRGB,
5+
} from "@microsoft/fast-colors";
6+
import { Swatch, SwatchRGB } from "./swatch";
7+
import { binarySearch } from "./utilities/binary-search";
8+
import { directionByIsDark } from "./utilities/direction-by-is-dark";
9+
import { contrast, RelativeLuminance } from "./utilities/relative-luminance";
10+
11+
/**
12+
* A collection of {@link Swatch} instances
13+
* @public
14+
*/
15+
export interface Palette<T extends Swatch = Swatch> {
16+
readonly source: T;
17+
readonly swatches: ReadonlyArray<T>;
18+
19+
/**
20+
* Returns a swatch from the palette that most closely matches
21+
* the contrast ratio provided to a provided reference.
22+
*/
23+
colorContrast(
24+
reference: Swatch,
25+
contrast: number,
26+
initialIndex?: number,
27+
direction?: 1 | -1
28+
): Swatch;
29+
30+
/**
31+
* Returns the index of the palette that most closely matches
32+
* the relativeLuminance of the provided swatch
33+
*/
34+
closestIndexOf(reference: RelativeLuminance): number;
35+
36+
/**
37+
* Gets a swatch by index. Index is clamped to the limits
38+
* of the palette so a Swatch will always be returned.
39+
*/
40+
get(index: number): T;
41+
}
42+
43+
/**
44+
* A {@link Palette} representing RGB swatch values.
45+
* @public
46+
*/
47+
export class PaletteRGB implements Palette<SwatchRGB> {
48+
/**
49+
* {@inheritdoc Palette.source}
50+
*/
51+
public readonly source: SwatchRGB;
52+
public readonly swatches: ReadonlyArray<SwatchRGB>;
53+
private lastIndex: number;
54+
private reversedSwatches: ReadonlyArray<SwatchRGB>;
55+
/**
56+
*
57+
* @param source - The source color for the palette
58+
* @param swatches - All swatches in the palette
59+
*/
60+
constructor(source: SwatchRGB, swatches: ReadonlyArray<SwatchRGB>) {
61+
this.source = source;
62+
this.swatches = swatches;
63+
64+
this.reversedSwatches = Object.freeze([...this.swatches].reverse());
65+
this.lastIndex = this.swatches.length - 1;
66+
}
67+
68+
/**
69+
* {@inheritdoc Palette.colorContrast}
70+
*/
71+
public colorContrast(
72+
reference: Swatch,
73+
contrastTarget: number,
74+
initialSearchIndex?: number,
75+
direction?: 1 | -1
76+
): SwatchRGB {
77+
if (initialSearchIndex === undefined) {
78+
initialSearchIndex = this.closestIndexOf(reference);
79+
}
80+
81+
let source: ReadonlyArray<SwatchRGB> = this.swatches;
82+
const endSearchIndex = this.lastIndex;
83+
let startSearchIndex = initialSearchIndex;
84+
85+
if (direction === undefined) {
86+
direction = directionByIsDark(reference);
87+
}
88+
89+
const condition = (value: SwatchRGB) =>
90+
contrast(reference, value) >= contrastTarget;
91+
92+
if (direction === -1) {
93+
source = this.reversedSwatches;
94+
startSearchIndex = endSearchIndex - startSearchIndex;
95+
}
96+
97+
return binarySearch(source, condition, startSearchIndex, endSearchIndex);
98+
}
99+
100+
/**
101+
* {@inheritdoc Palette.get}
102+
*/
103+
public get(index: number): SwatchRGB {
104+
return this.swatches[index] || this.swatches[clamp(index, 0, this.lastIndex)];
105+
}
106+
107+
/**
108+
* {@inheritdoc Palette.closestIndexOf}
109+
*/
110+
public closestIndexOf(reference: Swatch): number {
111+
const index = this.swatches.indexOf(reference as SwatchRGB);
112+
113+
if (index !== -1) {
114+
return index;
115+
}
116+
117+
const closest = this.swatches.reduce((previous, next) =>
118+
Math.abs(next.relativeLuminance - reference.relativeLuminance) <
119+
Math.abs(previous.relativeLuminance - reference.relativeLuminance)
120+
? next
121+
: previous
122+
);
123+
124+
return this.swatches.indexOf(closest);
125+
}
126+
127+
/**
128+
* Create a color palette from a provided swatch
129+
* @param source - The source swatch to create a palette from
130+
* @returns
131+
*/
132+
static from(source: SwatchRGB) {
133+
return new PaletteRGB(
134+
source,
135+
Object.freeze(
136+
new ComponentStateColorPalette({
137+
baseColor: source,
138+
}).palette.map(x => {
139+
const _x = parseColorHexRGB(x.toStringHexRGB())!;
140+
return new SwatchRGB(_x.r, _x.g, _x.b);
141+
})
142+
)
143+
);
144+
}
145+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { inRange } from "lodash";
2+
import { Palette } from "../palette";
3+
import { Swatch } from "../swatch";
4+
import { isDark } from "../utilities/is-dark";
5+
6+
/**
7+
* @internal
8+
*/
9+
export function accentFill(
10+
palette: Palette,
11+
neutralPalette: Palette,
12+
reference: Swatch,
13+
textColor: Swatch,
14+
contrastTarget: number,
15+
hoverDelta: number,
16+
activeDelta: number,
17+
focusDelta: number,
18+
selectedDelta: number,
19+
neutralFillRestDelta: number,
20+
neutralFillHoverDelta: number,
21+
neutralFillActiveDelta: number
22+
) {
23+
const accent = palette.source;
24+
const referenceIndex = neutralPalette.closestIndexOf(reference);
25+
const swapThreshold = Math.max(
26+
neutralFillRestDelta,
27+
neutralFillHoverDelta,
28+
neutralFillActiveDelta
29+
);
30+
const direction = referenceIndex >= swapThreshold ? -1 : 1;
31+
const paletteLength = palette.swatches.length;
32+
const maxIndex = paletteLength - 1;
33+
const accentIndex = palette.closestIndexOf(accent);
34+
let accessibleOffset = 0;
35+
36+
while (
37+
accessibleOffset < direction * hoverDelta &&
38+
inRange(accentIndex + accessibleOffset + direction, 0, paletteLength) &&
39+
textColor.contrast(palette.get(accentIndex + accessibleOffset + direction)) >=
40+
contrastTarget &&
41+
inRange(accentIndex + accessibleOffset + direction + direction, 0, maxIndex)
42+
) {
43+
accessibleOffset += direction;
44+
}
45+
46+
const hoverIndex = accentIndex + accessibleOffset;
47+
const restIndex = hoverIndex + direction * -1 * hoverDelta;
48+
const activeIndex = restIndex + direction * activeDelta;
49+
const focusIndex = restIndex + direction * focusDelta;
50+
const selectedIndex =
51+
restIndex + (isDark(reference) ? selectedDelta * -1 : selectedDelta);
52+
53+
return {
54+
rest: palette.get(restIndex),
55+
hover: palette.get(hoverIndex),
56+
active: palette.get(activeIndex),
57+
focus: palette.get(focusIndex),
58+
selected: palette.get(selectedIndex),
59+
};
60+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { Swatch } from "../swatch";
2+
import { black, white } from "../utilities/color-constants";
3+
4+
/**
5+
* @internal
6+
*/
7+
export function accentForegroundCut(reference: Swatch, contrastTarget: number) {
8+
return reference.contrast(white) >= contrastTarget ? white : black;
9+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { Palette } from "../palette";
2+
import { Swatch } from "../swatch";
3+
import { directionByIsDark } from "../utilities/direction-by-is-dark";
4+
5+
/**
6+
* @internal
7+
*/
8+
export function accentForeground(
9+
palette: Palette,
10+
reference: Swatch,
11+
contrastTarget: number,
12+
restDelta: number,
13+
hoverDelta: number,
14+
activeDelta: number,
15+
focusDelta: number
16+
) {
17+
const accent = palette.source;
18+
const accentIndex = palette.closestIndexOf(accent);
19+
const direction = directionByIsDark(reference);
20+
const startIndex =
21+
accentIndex +
22+
(direction === 1
23+
? Math.min(restDelta, hoverDelta)
24+
: Math.max(direction * restDelta, direction * hoverDelta));
25+
const accessibleSwatch = palette.colorContrast(
26+
reference,
27+
contrastTarget,
28+
startIndex,
29+
direction
30+
);
31+
const accessibleIndex1 = palette.closestIndexOf(accessibleSwatch);
32+
const accessibleIndex2 =
33+
accessibleIndex1 + direction * Math.abs(restDelta - hoverDelta);
34+
const indexOneIsRestState =
35+
direction === 1
36+
? restDelta < hoverDelta
37+
: direction * restDelta > direction * hoverDelta;
38+
39+
let restIndex: number;
40+
let hoverIndex: number;
41+
42+
if (indexOneIsRestState) {
43+
restIndex = accessibleIndex1;
44+
hoverIndex = accessibleIndex2;
45+
} else {
46+
restIndex = accessibleIndex2;
47+
hoverIndex = accessibleIndex1;
48+
}
49+
50+
return {
51+
rest: palette.get(restIndex),
52+
hover: palette.get(hoverIndex),
53+
active: palette.get(restIndex + direction * activeDelta),
54+
focus: palette.get(restIndex + direction * focusDelta),
55+
};
56+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { Swatch } from "../swatch";
2+
import { Palette } from "../palette";
3+
import { directionByIsDark } from "../utilities/direction-by-is-dark";
4+
5+
/**
6+
* The neutralDivider color recipe
7+
* @param palette - The palette to operate on
8+
* @param reference - The reference color
9+
* @param delta - The offset from the reference
10+
*
11+
* @internal
12+
*/
13+
export function neutralDivider(palette: Palette, reference: Swatch, delta: number) {
14+
return palette.get(
15+
palette.closestIndexOf(reference) + directionByIsDark(reference) * delta
16+
);
17+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { Palette } from "../palette";
2+
import { Swatch } from "../swatch";
3+
4+
/**
5+
* @internal
6+
*/
7+
export function neutralFillCard(palette: Palette, reference: Swatch, delta: number) {
8+
const referenceIndex = palette.closestIndexOf(reference);
9+
10+
return palette.get(referenceIndex - (referenceIndex < delta ? delta * -1 : delta));
11+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { Palette } from "../palette";
2+
import { Swatch } from "../swatch";
3+
import { directionByIsDark } from "../utilities/direction-by-is-dark";
4+
5+
/**
6+
* @internal
7+
*/
8+
export function neutralFillInput(
9+
palette: Palette,
10+
reference: Swatch,
11+
restDelta: number,
12+
hoverDelta: number,
13+
activeDelta: number,
14+
focusDelta: number,
15+
selectedDelta: number
16+
) {
17+
const direction = directionByIsDark(reference);
18+
const referenceIndex = palette.closestIndexOf(reference);
19+
20+
return {
21+
rest: palette.get(referenceIndex - direction * restDelta),
22+
hover: palette.get(referenceIndex - direction * hoverDelta),
23+
active: palette.get(referenceIndex - direction * activeDelta),
24+
focus: palette.get(referenceIndex - direction * focusDelta),
25+
selected: palette.get(referenceIndex - direction * selectedDelta),
26+
};
27+
}

0 commit comments

Comments
 (0)