Skip to content

Commit

Permalink
feat: auto dark colors
Browse files Browse the repository at this point in the history
  • Loading branch information
sastan committed Feb 12, 2022
1 parent 9fc5bae commit 2f8f69d
Show file tree
Hide file tree
Showing 13 changed files with 331 additions and 50 deletions.
57 changes: 57 additions & 0 deletions .changeset/good-beds-explode.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
---
'twind': patch
---

feat: auto dark colors

If enabled, automatic dark colors are generated for each light color (eg no `dark:` variant is present). This feature is opt-in and twind provides a builtin function that works with [tailwind color palettes](https://tailwindcss.com/docs/customizing-colors) (`50`, `100`, `200`, ..., `800`, `900`).

```ts
import { autoDarkColor } from 'twind'

defineConfig({
// for tailwind color palettes: 50 -> 900, 100 -> 800, ..., 800 -> 100, 900 -> 50
darkColor: autoDarkColor,
// other possible implementations
darkColor: (section, key, { theme }) => theme(`${section}.${key}-dark`) as ColorValue,
darkColor: (section, key, { theme }) => theme(`dark.${section}.${key}`) as ColorValue,
darkColor: (section, key, { theme }) => theme(`${section}.dark.${key}`) as ColorValue,
darkColor: (section, key, context, lightColor) => generateDarkColor(lightColor),
})
```

Example css for `text-gray-900`:

```css
.text-gray-900 {
--tw-text-opacity: 1;
color: rgba(15, 23, 42, var(--tw-text-opacity));
}
@media (prefers-color-scheme: dark) {
.text-gray-900 {
--tw-text-opacity: 1;
color: rgba(248, 250, 252, var(--tw-text-opacity));
}
}
```

The auto-generated dark color can be overridden by the usual `dark:...` variant: `text-gray-900 dark:text-gray-100`.

```css
.text-gray-900 {
--tw-text-opacity: 1;
color: rgba(15, 23, 42, var(--tw-text-opacity));
}
@media (prefers-color-scheme: dark) {
.text-gray-900 {
--tw-text-opacity: 1;
color: rgba(248, 250, 252, var(--tw-text-opacity));
}
}
@media (prefers-color-scheme: dark) {
.dark\\:text-gray-100 {
--tw-text-opacity: 1;
color: rgba(241, 245, 249, var(--tw-text-opacity));
}
}
```
1 change: 0 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,6 @@
- cdn.twind.dev -> https://cdn.jsdelivr.net/npm/@twind/cdn@next
- rewrite https://github.com/TanStack/tanstack.com
- ci: post on discord after release
- auto support dark mode in theme helpers (`<section>.dark.<key>` or `dark.<section>.<key>`)
- @twind/completions — provide autocompletion for classNames
- https://npm.runkit.com/regexp-enumerator
- https://www.npmjs.com/package/randexp
Expand Down
10 changes: 5 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,33 +43,33 @@
"name": "twind",
"path": "packages/twind/dist/twind.esnext.js",
"brotli": true,
"limit": "7.1kb"
"limit": "7.3kb"
},
{
"name": "twind (setup)",
"path": "packages/twind/dist/twind.esnext.js",
"import": "{ setup }",
"brotli": true,
"limit": "4.8kb"
"limit": "5.1kb"
},
{
"name": "twind (twind + cssom)",
"path": "packages/twind/dist/twind.esnext.js",
"import": "{ twind, cssom }",
"brotli": true,
"limit": "4.15kb"
"limit": "4.45kb"
},
{
"name": "@twind/cdn",
"path": "packages/cdn/dist/cdn.esnext.js",
"brotli": true,
"limit": "14.7kb"
"limit": "14.75kb"
},
{
"name": "@twind/preset-tailwind",
"path": "packages/preset-tailwind/dist/preset-tailwind.esnext.js",
"brotli": true,
"limit": "9.1kb",
"limit": "9kb",
"ignore": [
"twind"
]
Expand Down
2 changes: 1 addition & 1 deletion packages/cdn/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
"name": "@twind/cdn",
"path": "dist/cdn.esnext.js",
"brotli": true,
"limit": "14.7kb"
"limit": "14.75kb"
}
],
"dependencies": {
Expand Down
6 changes: 3 additions & 3 deletions packages/twind/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,21 +29,21 @@
"name": "twind",
"path": "dist/twind.esnext.js",
"brotli": true,
"limit": "7.1kb"
"limit": "7.3kb"
},
{
"name": "twind (setup)",
"path": "dist/twind.esnext.js",
"import": "{ setup }",
"brotli": true,
"limit": "4.8kb"
"limit": "5.1kb"
},
{
"name": "twind (twind + cssom)",
"path": "dist/twind.esnext.js",
"import": "{ twind, cssom }",
"brotli": true,
"limit": "4.15kb"
"limit": "4.45kb"
}
],
"dependencies": {
Expand Down
36 changes: 35 additions & 1 deletion packages/twind/src/colors.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ColorValue, ColorFunctionOptions } from './types'
import type { ColorValue, ColorFunctionOptions, Context, Falsey } from './types'

function parseColorComponent(chars: string, factor: number): number {
return Math.round(parseInt(chars, 16) * factor)
Expand Down Expand Up @@ -30,3 +30,37 @@ export function toColorValue(color: ColorValue, options: ColorFunctionOptions =

return color
}

/**
* Looks for a matching dark color within a [tailwind color palette](https://tailwindcss.com/docs/customizing-colors) (`50`, `100`, `200`, ..., `800`, `900`).
*
* ```js
* defineConfig({
* darkColor: autoDarkColor,
* })
* ```
*
* **Note**: Does not work for arbitrary values like `[theme(colors.gray.500)]` or `[theme(colors.gray.500, #ccc)]`.
*
* @param section within theme to use
* @param key of the light color or an arbitrary value
* @param context to use
* @returns the dark color if found
*/
export function autoDarkColor(
section: string,
key: string,
{ theme }: Context<any>,
): ColorValue | Falsey {
// 50 -> 900, 100 -> 800, ..., 800 -> 100, 900 -> 50
// key: gray-50, gray.50
key = key.replace(
/\d+$/,
(shade) =>
// ~~(parseInt(shade, 10) / 100): 50 -> 0, 900 -> 9
// (9 - 0) -> 900, (9 - 9) -> 50
((9 - ~~(parseInt(shade, 10) / 100) || 0.5) * 100) as any,
)

return theme(section as 'colors', key)
}
4 changes: 4 additions & 0 deletions packages/twind/src/define-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export function defineConfig<Theme = BaseTheme, Presets extends Preset<any>[] =
let config: TwindConfig<BaseTheme & ExtractThemes<Theme, Presets>> = {
preflight: userConfig.preflight !== false && [],
darkMode: undefined,
darkColor: undefined,
theme: {},
variants: asArray(userConfig.variants),
rules: asArray(userConfig.rules),
Expand All @@ -30,13 +31,15 @@ export function defineConfig<Theme = BaseTheme, Presets extends Preset<any>[] =
...presets,
{
darkMode: userConfig.darkMode,
darkColor: userConfig.darkColor,
preflight: userConfig.preflight !== false && asArray(userConfig.preflight),
theme: userConfig.theme as TwindConfig<BaseTheme & ExtractThemes<Theme, Presets>>['theme'],
} as TwindPresetConfig<Theme>,
])) {
const {
preflight,
darkMode = config.darkMode,
darkColor = config.darkColor,
theme,
variants,
rules,
Expand All @@ -51,6 +54,7 @@ export function defineConfig<Theme = BaseTheme, Presets extends Preset<any>[] =
preflight !== false && [...config.preflight, ...asArray(preflight)],

darkMode,
darkColor,

theme: {
...config.theme,
Expand Down
35 changes: 21 additions & 14 deletions packages/twind/src/internal/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { asArray, escape, hash as defaultHash, identity } from '../utils'
type ResolveFunction<Theme extends BaseTheme = BaseTheme> = (
className: string,
context: Context<Theme>,
isDark?: boolean,
) => RuleResult

type VariantFunction<Theme extends BaseTheme = BaseTheme> = (
Expand Down Expand Up @@ -98,26 +99,31 @@ export function createContext<Theme extends BaseTheme = BaseTheme>({
return variantCache.get(value) as string
},

r(value) {
if (!ruleCache.has(value)) {
r(className, isDark) {
const key = JSON.stringify([className, isDark])
if (!ruleCache.has(key)) {
ruleCache.set(
value,
key,
// TODO console.warn(`[twind] unknown rule "${value}"`),
!ignored(value, this) && find(value, rules, ruleResolvers, getRuleResolver, this),
!ignored(className, this) &&
find(className, rules, ruleResolvers, getRuleResolver, this, isDark),
)
}

return ruleCache.get(value)
return ruleCache.get(key)
},
}
}

function find<Value, Config, Result, Theme extends BaseTheme = BaseTheme>(
value: Value,
list: Config[],
cache: Map<Config, (value: Value, context: Context<Theme>) => Result>,
getResolver: (item: Config) => (value: Value, context: Context<Theme>) => Result,
cache: Map<Config, (value: Value, context: Context<Theme>, isDark?: boolean) => Result>,
getResolver: (
item: Config,
) => (value: Value, context: Context<Theme>, isDark?: boolean) => Result,
context: Context<Theme>,
isDark?: boolean,
) {
for (const item of list) {
let resolver = cache.get(item)
Expand All @@ -126,7 +132,7 @@ function find<Value, Config, Result, Theme extends BaseTheme = BaseTheme>(
cache.set(item, (resolver = getResolver(item)))
}

const resolved = resolver(value, context)
const resolved = resolver(value, context, isDark)

if (resolved) return resolved
}
Expand Down Expand Up @@ -191,13 +197,14 @@ function maybeNegate($_: string, value: string): string {
function createResolve<Result, Theme extends BaseTheme = BaseTheme>(
patterns: MaybeArray<string | RegExp>,
resolve: (match: MatchResult, context: Context<Theme>) => Result,
): (value: string, context: Context<Theme>) => Result | undefined {
return createRegExpExecutor(patterns, (value, condition, context) => {
): (value: string, context: Context<Theme>, isDark?: boolean) => Result | undefined {
return createRegExpExecutor(patterns, (value, condition, context, isDark?: boolean) => {
const match = condition.exec(value) as MatchResult | Falsey

if (match) {
// MATCH.$_ = value
match.$$ = value.slice(match[0].length)
match.dark = isDark

return resolve(match, context)
}
Expand All @@ -206,13 +213,13 @@ function createResolve<Result, Theme extends BaseTheme = BaseTheme>(

function createRegExpExecutor<Result, Theme extends BaseTheme = BaseTheme>(
patterns: MaybeArray<string | RegExp>,
run: (value: string, condition: RegExp, context: Context<Theme>) => Result,
): (value: string, context: Context<Theme>) => Result | undefined {
run: (value: string, condition: RegExp, context: Context<Theme>, isDark?: boolean) => Result,
): (value: string, context: Context<Theme>, isDark?: boolean) => Result | undefined {
const conditions = asArray(patterns).map(toCondition)

return (value, context) => {
return (value, context, isDark) => {
for (const condition of conditions) {
const result = run(value, condition, context)
const result = run(value, condition, context, isDark)

if (result) return result
}
Expand Down
2 changes: 1 addition & 1 deletion packages/twind/src/internal/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,5 @@ export function resolve<Theme extends BaseTheme = BaseTheme>(
): RuleResult | TwindRule[] {
const factory = registry.get(rule.n)

return factory ? factory(rule, context as any) : context.r(rule.n)
return factory ? factory(rule, context as any) : context.r(rule.n, rule.v[0] == 'dark')
}
58 changes: 41 additions & 17 deletions packages/twind/src/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,34 +151,58 @@ export function colorFromTheme<
(context.theme(opacitySection, opacityMatch || 'DEFAULT') as string | undefined) ||
(opacityMatch && arbitrary(opacityMatch, opacitySection, context))

const color = toColorValue(colorValue, {
opacityVariable: opacityVariable || undefined,
opacityValue: opacityValue || undefined,
})

// if (typeof color != 'string') {
// console.warn(`Invalid color ${colorMatch} (from ${match.input}):`, color)
// return
// }

if (resolve) {
;(match as ThemeMatchResult<ColorFromThemeValue>)._ = {
value: color,
color: (options) => toColorValue(colorValue, options),
}
const create =
resolve ||
((match) => {
const properties: Record<string, string> = {}

return resolve(match as ThemeMatchResult<ColorFromThemeValue>, context)
}
const color = match._.value

if (opacityVariable && color.includes(opacityVariable)) {
properties[opacityVariable] = opacityValue || '1'
}

const properties = {} as Record<string, string>
properties[property] = color

if (opacityVariable && color.includes(opacityVariable)) {
properties[opacityVariable] = opacityValue || '1'
return (selector ? { [selector]: properties } : properties) as CSSObject
})

;(match as ThemeMatchResult<ColorFromThemeValue>)._ = {
value: toColorValue(colorValue, {
opacityVariable: opacityVariable || undefined,
opacityValue: opacityValue || undefined,
}),
color: (options) => toColorValue(colorValue, options),
}

properties[property] = color
let properties = create(match as ThemeMatchResult<ColorFromThemeValue>, context)

// auto support dark mode colors
if (!match.dark) {
const darkColorValue = context.d(section, colorMatch, colorValue)

if (darkColorValue && darkColorValue !== colorValue) {
;(match as ThemeMatchResult<ColorFromThemeValue>)._ = {
value: toColorValue(darkColorValue, {
opacityVariable: opacityVariable || undefined,
opacityValue: opacityValue || undefined,
}),
color: (options) => toColorValue(darkColorValue, options),
}

properties = {
'&': properties,
[context.v('dark')]: create(match as ThemeMatchResult<ColorFromThemeValue>, context),
} as CSSObject
}
}

return (selector ? { [selector]: properties } : properties) as CSSObject
return properties
}
}

Expand Down
Loading

0 comments on commit 2f8f69d

Please sign in to comment.