-
Notifications
You must be signed in to change notification settings - Fork 33
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
feat(token lab): editable color tokens #428
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,25 +1,151 @@ | ||
.btn { | ||
background: none; | ||
border: 1px solid var(--tz-border-3); | ||
border-radius: 0; | ||
font-size: inherit; | ||
font-weight: inherit; | ||
.container { | ||
display: grid; | ||
grid-auto-flow: column; | ||
width: 28rem; | ||
height: 1.5rem; | ||
gap: 1rem; | ||
grid-template-columns: 3fr 20fr 5fr; | ||
contain: inline-size; | ||
Comment on lines
+4
to
+7
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Most parameters for the layout of the row are centralized here. |
||
place-items: center; | ||
} | ||
|
||
.swatch { | ||
background: var(--color); | ||
border: 1px solid oklch(0 0 0 / 20%); | ||
border-radius: 0.25rem; | ||
padding: 0; | ||
width: 100%; | ||
height: 100%; | ||
cursor: pointer; | ||
|
||
&:hover { | ||
border-color: var(--tz-color-border-2); | ||
} | ||
|
||
&:focus-visible { | ||
outline: 2px solid var(--tz-color-focus); | ||
outline: 2px solid var(--tz-color-base-lime-800); | ||
outline-offset: 2px; | ||
Comment on lines
-14
to
+26
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This token does not exist. Could there be others leftover from refactors? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes we’ve gone through a few refactors and there are some stragglers. Thanks for fixing! |
||
} | ||
} | ||
|
||
.swatch { | ||
background: var(--color); | ||
.popover { | ||
display: grid; | ||
background: var(--tz-color-bg-1); | ||
padding: 1rem; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
filter: drop-shadow(4px 8px 8px oklch(0.1 0 0 / 0.5)); | ||
place-items: center; | ||
border-radius: 1rem; | ||
transition-property: opacity, translate; | ||
transition-duration: 0.2s; | ||
transition-timing-function: ease-in-out; | ||
} | ||
|
||
.popover::after { | ||
content: ''; | ||
display: block; | ||
background-color: var(--tz-color-bg-1); | ||
height: 1rem; | ||
width: 1rem; | ||
} | ||
|
||
.popover[data-side=bottom] { | ||
padding-top: 0; /* the triangle is going to do double duty as the top padding */ | ||
@starting-style { | ||
opacity: 0; | ||
translate: 0 2rem; | ||
} | ||
&::after { | ||
Comment on lines
+53
to
+56
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. First starting style rule of the codebase 🎉 |
||
grid-row: 1; | ||
translate: 0 -8px; | ||
mask: conic-gradient( | ||
from 135deg at top, | ||
black 90deg, | ||
transparent 91deg, | ||
transparent 359deg, | ||
black 360deg | ||
) top / 100% 50% no-repeat; | ||
} | ||
} | ||
|
||
.popover[data-side=top] { | ||
padding-bottom: 0; | ||
@starting-style { | ||
opacity: 0; | ||
translate: 0 -2rem; | ||
} | ||
&::after { | ||
grid-row: 2; | ||
translate: 0 8px; | ||
mask: conic-gradient( | ||
from -45deg at bottom, | ||
black 90deg, | ||
transparent 91deg, | ||
transparent 359deg, | ||
black 360deg | ||
) bottom / 100% 50% no-repeat; | ||
} | ||
} | ||
|
||
.channels { | ||
display: grid; | ||
width: 100%; | ||
grid-template-columns: repeat(4, 1fr); | ||
outline: var(--tz-border-3); | ||
border-radius: 0.25rem; | ||
cursor: pointer; | ||
height: 2rem; | ||
width: 3rem; | ||
height: 100%; | ||
contain: inline-size; | ||
} | ||
|
||
.colorSpaceDropdown { | ||
font-family: var(--tz-font-mono); | ||
color: var(--tz-color-text-1); | ||
height: 100%; | ||
contain: size paint; | ||
border-radius: initial; | ||
display: grid; | ||
padding: 0 0.5rem; | ||
justify-content: stretch; | ||
|
||
& > :nth-child(1) { | ||
grid-area: 1/1; | ||
justify-self: start; | ||
} | ||
& > :nth-child(2) { | ||
grid-area: 1/1; | ||
justify-self: end; | ||
} | ||
& :global(.tz-select-item-inner) { | ||
display: block; | ||
} | ||
& :global(.tz-select-item-icon) { | ||
display: none; | ||
} | ||
&:hover, &:focus-visible { | ||
outline-color: transparent; | ||
background: var(--tz-color-bg-2); | ||
} | ||
} | ||
|
||
.colorSpaceDropdownItem { | ||
font-family: var(--tz-font-mono); | ||
} | ||
|
||
.channel { | ||
display: inline-block; | ||
padding: 0 0.5rem; | ||
font-family: var(--tz-font-mono); | ||
border-left: var(--tz-border-3); | ||
color: var(--tz-color-text-1); | ||
align-content: center; | ||
} | ||
|
||
.alpha { | ||
display: inline-block; | ||
width: 100%; | ||
height: 100%; | ||
padding: 0 0.5rem; | ||
font-family: var(--tz-font-mono); | ||
outline: var(--tz-border-3); | ||
border-radius: 0.25rem; | ||
align-content: center; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,5 +1,13 @@ | ||
declare const classNames: { | ||
readonly btn: "btn"; | ||
readonly container: "container"; | ||
readonly swatch: "swatch"; | ||
readonly popover: "popover"; | ||
readonly channels: "channels"; | ||
readonly colorSpaceDropdown: "colorSpaceDropdown"; | ||
readonly "tz-select-item-inner": "tz-select-item-inner"; | ||
readonly "tz-select-item-icon": "tz-select-item-icon"; | ||
readonly colorSpaceDropdownItem: "colorSpaceDropdownItem"; | ||
readonly channel: "channel"; | ||
readonly alpha: "alpha"; | ||
}; | ||
export = classNames; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,5 +1,9 @@ | ||
import * as Popover from '@radix-ui/react-popover'; | ||
import { ChevronDown } from '@terrazzo/icons'; | ||
import ColorPicker, { COLOR_PICKER_SPACES } from '@terrazzo/react-color-picker'; | ||
import { Select, SelectItem } from '@terrazzo/tiles'; | ||
import type { ColorValueNormalized } from '@terrazzo/token-tools'; | ||
import useColor from '@terrazzo/use-color'; | ||
import useColor, { ColorOutput } from '@terrazzo/use-color'; | ||
import c from './EditableColorToken.module.css'; | ||
|
||
export interface EditableColorTokenProps { | ||
|
@@ -9,11 +13,67 @@ export interface EditableColorTokenProps { | |
} | ||
|
||
export default function EditableColorToken({ id, mode = '.', value }: EditableColorTokenProps) { | ||
const [color] = useColor(value); | ||
const [color, setColor] = useColor(value); | ||
const channels = normalizedChannels(color); | ||
|
||
return ( | ||
<button className={c.btn} type='button' aria-label={`Edit ${id}`}> | ||
<div className={c.swatch} style={{ '--color': color.css }} /> | ||
</button> | ||
); | ||
return <div className={c.container}> | ||
<Popover.Root> | ||
<Popover.Trigger className={c.swatch} aria-label={`Edit ${id}`} style={{ '--color': color.css }}/> | ||
<Popover.Portal> | ||
<Popover.Content className={c.popover}> | ||
<ColorPicker color={color} setColor={setColor} /> | ||
</Popover.Content> | ||
</Popover.Portal> | ||
</Popover.Root> | ||
<div className={c.channels}> | ||
<Select | ||
className={c.colorSpaceDropdown} | ||
value={color.original.mode} | ||
trigger={color.original.mode} | ||
triggerIcon={<ChevronDown />} | ||
onValueChange={space => setColor(color[space as keyof typeof color] ?? color.original)} | ||
> | ||
{Object.entries(COLOR_PICKER_SPACES).map(([id, label]) => ( | ||
<SelectItem className={c.colorSpaceDropdownItem} key={id} value={id}> | ||
{label} | ||
</SelectItem> | ||
))} | ||
</Select> | ||
{channels.map((v, i) => <output className={c.channel} key={i}>{trimTrailingZeros(String(v).slice(0, 6))}</output>)} | ||
</div> | ||
<output className={c.alpha}>{trimTrailingZeros(String((color.original.alpha * 100)).slice(0, 6))}%</output> | ||
Comment on lines
+42
to
+44
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Went with 5 digits (6 characters) of precision to match the design, but it feels kinda excessive. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I wonder if using It could just as well complicate it too though. Just a thought. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah I’ve gone back-and-forth about this. If we’re shooting for 24-bit color, we need 10–12 digits or so:
16-bit color is probably a better target because today many devices are in the ~10 bit color range. 16-bit color is fairly safely-represented at ~7 significant digits.
The fewer decimals you have, the less fidelity and colors will “snap” to the wrong color. The designs probably should have more decimals than they do now. Also +1 I think |
||
</div>; | ||
} | ||
|
||
function normalizedChannels({ original }: ColorOutput): ColorValueNormalized["channels"] { | ||
switch (original.mode) { | ||
case 'a98': | ||
case 'lrgb': | ||
case 'p3': | ||
case 'prophoto': | ||
case 'rec2020': | ||
case 'rgb': | ||
return [original.r, original.g, original.b]; | ||
case 'hsl': | ||
case 'okhsl': | ||
return [original.h, original.s, original.l]; | ||
case 'hsv': | ||
case 'okhsv': | ||
return [original.h, original.s, original.v]; | ||
case 'hwb': | ||
return [original.h, original.w, original.b]; | ||
case 'lab': | ||
case 'oklab': | ||
return [original.l, original.a, original.b]; | ||
case 'lch': | ||
case 'oklch': | ||
return [original.l, original.c, original.h]; | ||
case 'xyz50': | ||
case 'xyz65': | ||
return [original.x, original.y, original.z]; | ||
} | ||
} | ||
|
||
function trimTrailingZeros(value: string) { | ||
return value.replace(/\.0+$/, '').replace(/(?<=\.[^\.]+)0+$/, ''); | ||
} | ||
Comment on lines
+77
to
79
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 0.2300 => 0.23 |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,3 +1,4 @@ | ||
// @ts-check | ||
import { tokenToCulori } from '@terrazzo/token-tools'; | ||
import { | ||
inGamut, | ||
|
@@ -59,13 +60,13 @@ export function cleanValue(value, precision = 5, normalized = true) { | |
} | ||
|
||
/** Primary parse logic */ | ||
export function parse(color) { | ||
export function parse(/** @type {import("./index.d.ts").ColorInput} */ color) { | ||
if (color && typeof color === 'object') { | ||
let normalizedColor = color; | ||
|
||
// DTCG tokens: convert to Culori format | ||
if (color.colorSpace && Array.isArray(color.channels)) { | ||
normalizedColor = tokenToCulori(); | ||
normalizedColor = tokenToCulori(color); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The color token component would not even render without this change. video.mp4 |
||
} | ||
if (!normalizedColor.mode) { | ||
throw new Error(`Invalid Culori color: ${JSON.stringify(normalizedColor)}`); | ||
|
@@ -248,6 +249,11 @@ export function createMemoizedColor(color) { | |
this.lab = COLORSPACES.lab.converter(color); | ||
return this.lab; | ||
}, | ||
get lch() { | ||
delete this.lch; | ||
this.lch = COLORSPACES.lch.converter(color); | ||
return this.lch; | ||
}, | ||
get lrgb() { | ||
delete this.lrgb; | ||
this.lrgb = COLORSPACES.lrgb.converter(color); | ||
|
@@ -318,7 +324,7 @@ export function createMemoizedColor(color) { | |
} | ||
|
||
/** memoize Culori colors and reduce unnecessary updates */ | ||
export default function useColor(color) { | ||
export default function useColor(/** @type {import("./index.d.ts").ColorInput} */ color) { | ||
const [innerColor, setInnerColor] = useState(createMemoizedColor(parse(color))); | ||
const setColorOutput = useCallback((newColor) => { | ||
if (newColor) { | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixes a layout issue on chromium
Screen.Recording.2025-01-27.093656.mp4