Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[web-components] refactor color recipes for DI #18199

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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": "chhol@microsoft.com",
"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';
chrisdholt marked this conversation as resolved.
Show resolved Hide resolved
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