Skip to content

Commit 10bb66b

Browse files
authored
fix(editor): derive font weights from installed variants (CoreBunch#191)
1 parent 1207372 commit 10bb66b

4 files changed

Lines changed: 232 additions & 1 deletion

File tree

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
2+
import { cleanup, render, screen } from '@testing-library/react'
3+
import type { FontEntry } from '@core/fonts'
4+
import { StyleSectionsEditor } from '@site/panels/PropertiesPanel/StyleSectionsEditor'
5+
import { setEditorPreference } from '@site/preferences/editorPreferences'
6+
import { useEditorStore } from '@site/store/store'
7+
import { makeSite } from '../fixtures'
8+
9+
const inter: FontEntry = {
10+
id: 'font-inter',
11+
source: 'google',
12+
family: 'Inter',
13+
variants: ['300', '400', '700', '900'],
14+
subsets: ['latin'],
15+
files: [
16+
{ variant: '300', subset: 'latin', path: '/uploads/fonts/inter/300-latin.woff2', format: 'woff2' },
17+
{ variant: '400', subset: 'latin', path: '/uploads/fonts/inter/400-latin.woff2', format: 'woff2' },
18+
{ variant: '700', subset: 'latin', path: '/uploads/fonts/inter/700-latin.woff2', format: 'woff2' },
19+
{ variant: '900', subset: 'latin', path: '/uploads/fonts/inter/900-latin.woff2', format: 'woff2' },
20+
],
21+
category: 'Sans Serif',
22+
createdAt: 1,
23+
updatedAt: 1,
24+
}
25+
26+
beforeEach(() => {
27+
setEditorPreference('propertiesSectionsExpanded', true)
28+
useEditorStore.setState({
29+
site: makeSite({
30+
settings: {
31+
shortcuts: {},
32+
fonts: {
33+
items: [inter],
34+
tokens: [
35+
{
36+
id: 'token-primary',
37+
name: 'Primary',
38+
variable: 'font-primary',
39+
familyId: inter.id,
40+
fallback: 'sans-serif',
41+
order: 0,
42+
createdAt: 1,
43+
updatedAt: 1,
44+
},
45+
],
46+
},
47+
},
48+
}),
49+
} as Parameters<typeof useEditorStore.setState>[0])
50+
})
51+
52+
afterEach(() => {
53+
cleanup()
54+
localStorage.clear()
55+
})
56+
57+
describe('font weight style options', () => {
58+
it('reflects the installed variants for the active font token', () => {
59+
renderFontWeightRow({ fontFamily: 'var(--font-primary)' })
60+
61+
expect(fontWeightOptionValues()).toEqual(['', '300', '400', '700', '900'])
62+
})
63+
64+
it('uses the default body font token when no explicit font family is set', () => {
65+
renderFontWeightRow({})
66+
67+
expect(fontWeightOptionValues()).toEqual(['', '300', '400', '700', '900'])
68+
})
69+
})
70+
71+
function renderFontWeightRow(styles: Record<string, unknown>): void {
72+
render(
73+
<StyleSectionsEditor
74+
storedStyles={styles}
75+
currentStyles={styles}
76+
sectionKey="base"
77+
styleQuery="font weight"
78+
onChange={() => {}}
79+
onRemove={() => {}}
80+
onClearProperty={() => {}}
81+
onClearProperties={() => {}}
82+
onPreview={() => {}}
83+
onClearPreview={() => {}}
84+
/>,
85+
)
86+
}
87+
88+
function fontWeightOptionValues(): string[] {
89+
const row = screen.getByTestId('css-property-row-fontWeight')
90+
const select = row.querySelector('select')
91+
expect(select).toBeInstanceOf(HTMLSelectElement)
92+
93+
return Array.from(select?.options ?? [], (option) => option.value)
94+
}

src/admin/pages/site/panels/PropertiesPanel/ClassPropertyRow.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { ColorControl } from '@site/property-controls/ColorControl'
1818
import { SelectControl } from '@site/property-controls/SelectControl'
1919
import { BackgroundImageControl } from '@site/property-controls/BackgroundImageControl'
2020
import { FontFamilyControl } from '@site/property-controls/FontFamilyControl'
21+
import { useEditorStore } from '@site/store/store'
2122
import { ControlRow } from '@ui/components/ControlRow'
2223
import { TokenAwareInput } from '@site/property-controls/TokenAwareInput'
2324
import {
@@ -35,6 +36,7 @@ import {
3536
cssPropertyLabel,
3637
NUMBER_TYPED_PROPS,
3738
} from './cssControlTypes'
39+
import { getFontWeightOptions } from './fontWeightOptions'
3840
import styles from './ClassPropertyRow.module.css'
3941

4042
// ---------------------------------------------------------------------------
@@ -45,6 +47,7 @@ interface ClassPropertyRowProps {
4547
property: keyof CSSPropertyBag
4648
value: string | number | undefined
4749
placeholder?: string | number
50+
fontFamilyValue?: unknown
4851
isSet?: boolean
4952
onChange: (property: keyof CSSPropertyBag, value: string | number | undefined) => void
5053
onRemove: (property: keyof CSSPropertyBag) => void
@@ -64,6 +67,7 @@ export function ClassPropertyRow({
6467
property,
6568
value,
6669
placeholder,
70+
fontFamilyValue,
6771
isSet = true,
6872
onChange,
6973
onRemove,
@@ -74,6 +78,7 @@ export function ClassPropertyRow({
7478
const tokenSource = getCSSPropertyTokenSource(property)
7579
const label = cssPropertyLabel(String(property))
7680
const placeholderText = placeholder !== undefined ? String(placeholder) : undefined
81+
const fonts = useEditorStore((state) => state.site?.settings.fonts ?? null)
7782

7883
// Always read both token catalogs — hooks must run unconditionally on
7984
// every render. The selected catalog is forwarded to TokenAwareInput
@@ -208,7 +213,10 @@ export function ClassPropertyRow({
208213
break
209214

210215
case 'select': {
211-
const opts = getEnumOptions(property) ?? []
216+
const enumOptions = getEnumOptions(property) ?? []
217+
const opts = property === 'fontWeight'
218+
? getFontWeightOptions(fontFamilyValue, fonts, enumOptions)
219+
: enumOptions
212220
control = (
213221
<SelectControl
214222
propKey={String(property)}

src/admin/pages/site/panels/PropertiesPanel/StyleSectionsEditor.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,7 @@ function StyleSectionGroup({
241241
property={prop}
242242
value={isSet ? (storedValue as string | number) : undefined}
243243
placeholder={!isSet ? fallbackValue : undefined}
244+
fontFamilyValue={currentStyles.fontFamily}
244245
isSet={isSet}
245246
onChange={onChange}
246247
onRemove={onRemove}
@@ -313,6 +314,7 @@ function AdvancedRows({
313314
property={prop}
314315
value={isSet ? (storedValue as string | number) : undefined}
315316
placeholder={!isSet ? fallbackValue : undefined}
317+
fontFamilyValue={currentStyles.fontFamily}
316318
isSet={isSet}
317319
onChange={onChange}
318320
onRemove={onRemove}
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import {
2+
fontFamilyStackForEntry,
3+
fontTokenCssVariable,
4+
parseVariant,
5+
sortFontTokens,
6+
type FontEntry,
7+
type FontToken,
8+
type SiteFontsSettings,
9+
} from '@core/fonts'
10+
11+
export function getFontWeightOptions(
12+
fontFamilyValue: unknown,
13+
fonts: SiteFontsSettings | null | undefined,
14+
fallbackOptions: readonly string[],
15+
): string[] {
16+
const entry = resolveFontEntryForWeightOptions(fontFamilyValue, fonts)
17+
const installedWeights = entry ? weightOptionsForEntry(entry) : []
18+
return installedWeights.length > 0 ? installedWeights : [...fallbackOptions]
19+
}
20+
21+
function resolveFontEntryForWeightOptions(
22+
fontFamilyValue: unknown,
23+
fonts: SiteFontsSettings | null | undefined,
24+
): FontEntry | undefined {
25+
const items = fontItems(fonts)
26+
if (items.length === 0) return undefined
27+
28+
const explicitFamily = typeof fontFamilyValue === 'string' ? fontFamilyValue.trim() : ''
29+
if (explicitFamily) {
30+
const entry = resolveExplicitFontEntry(explicitFamily, fonts, items)
31+
if (entry || !isUnsetFontFamilyValue(explicitFamily)) return entry
32+
}
33+
34+
return resolveDefaultBodyFontEntry(fonts, items)
35+
}
36+
37+
function resolveExplicitFontEntry(
38+
fontFamilyValue: string,
39+
fonts: SiteFontsSettings | null | undefined,
40+
items: readonly FontEntry[],
41+
): FontEntry | undefined {
42+
const tokenVariable = readCssVariableReference(fontFamilyValue)
43+
if (tokenVariable) {
44+
const token = fontTokens(fonts).find(
45+
(candidate) => fontTokenCssVariable(candidate.variable) === tokenVariable,
46+
)
47+
return token?.familyId ? items.find((entry) => entry.id === token.familyId) : undefined
48+
}
49+
50+
const normalizedStack = normalizeCssFamilyStack(fontFamilyValue)
51+
const exactStackMatch = items.find(
52+
(entry) => normalizeCssFamilyStack(fontFamilyStackForEntry(entry)) === normalizedStack,
53+
)
54+
if (exactStackMatch) return exactStackMatch
55+
56+
const firstFamily = normalizeCssFamilyName(readFirstCssFamily(fontFamilyValue))
57+
if (!firstFamily) return undefined
58+
return items.find((entry) => normalizeCssFamilyName(entry.family) === firstFamily)
59+
}
60+
61+
function resolveDefaultBodyFontEntry(
62+
fonts: SiteFontsSettings | null | undefined,
63+
items: readonly FontEntry[],
64+
): FontEntry | undefined {
65+
const tokens = fontTokens(fonts)
66+
const bodyToken = tokens[1] ?? tokens[0]
67+
if (bodyToken?.familyId) {
68+
const entry = items.find((item) => item.id === bodyToken.familyId)
69+
if (entry) return entry
70+
}
71+
return items[1] ?? items[0]
72+
}
73+
74+
function weightOptionsForEntry(entry: FontEntry): string[] {
75+
const weights = new Set<number>()
76+
const variants = [
77+
...entry.variants,
78+
...entry.files.map((file) => file.variant),
79+
]
80+
for (const variant of variants) {
81+
const parsed = parseVariant(variant)
82+
if (parsed) weights.add(parsed.weight)
83+
}
84+
return [...weights].sort((a, b) => a - b).map(String)
85+
}
86+
87+
function fontItems(fonts: SiteFontsSettings | null | undefined): readonly FontEntry[] {
88+
return Array.isArray(fonts?.items) ? fonts.items : []
89+
}
90+
91+
function fontTokens(fonts: SiteFontsSettings | null | undefined): FontToken[] {
92+
return Array.isArray(fonts?.tokens) ? sortFontTokens(fonts.tokens) : []
93+
}
94+
95+
function readCssVariableReference(value: string): string | undefined {
96+
const match = /^var\(\s*(--[a-zA-Z0-9_-]+)\s*(?:,[^)]+)?\)$/.exec(value)
97+
return match?.[1]
98+
}
99+
100+
function isUnsetFontFamilyValue(value: string): boolean {
101+
const normalized = value.toLowerCase()
102+
return normalized === 'inherit' || normalized === 'initial' || normalized === 'unset' || normalized === 'revert'
103+
}
104+
105+
function readFirstCssFamily(value: string): string {
106+
const trimmed = value.trim()
107+
if (!trimmed) return ''
108+
const quote = trimmed[0]
109+
if (quote === '"' || quote === "'") {
110+
for (let index = 1; index < trimmed.length; index += 1) {
111+
if (trimmed[index] === quote && trimmed[index - 1] !== '\\') {
112+
return trimmed.slice(1, index)
113+
}
114+
}
115+
return trimmed.slice(1)
116+
}
117+
const comma = trimmed.indexOf(',')
118+
return (comma === -1 ? trimmed : trimmed.slice(0, comma)).trim()
119+
}
120+
121+
function normalizeCssFamilyStack(value: string): string {
122+
return value.trim().replace(/\s+/g, ' ').toLowerCase()
123+
}
124+
125+
function normalizeCssFamilyName(value: string): string {
126+
return value.trim().replace(/^['"]|['"]$/g, '').toLowerCase()
127+
}

0 commit comments

Comments
 (0)