Skip to content

Commit 3cea7b8

Browse files
committed
feat: the primary button can take the brand color, aliases in customization.styles resolve in chains whatever the token order and fail on a cycle or an unknown token, a spinner inside a button is drawn in the button text color, the branding docs show the buttons aliased to primary AdminForth/2005
1 parent 7a47e04 commit 3cea7b8

10 files changed

Lines changed: 140 additions & 37 deletions

File tree

‎adminforth/design/system-concepts.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ Rules for the AdminForth UI (the "Hairline" theme). States and variants of every
99
- Keep the token list short. Reuse a token with the same role before adding one: muted icons and secondary text `InputIconColor`, faint text and empty values (`-`) `InputPlaceholderText`, subtle fill `SecondaryDarken` / `SecondaryLighten`, page dimming behind dialogs, modals and drawers `Scrim` (a photo lightbox stays `bg-black/*`, it is dark in both themes), a softer variant of a token is its opacity modifier (`stroke-lightPrimary/50`). Add a token for a new role, not for a new component, always as a `light*` / `dark*` pair.
1010
- Statuses use `Success`, `Warning` and `Danger` tokens: the base one for text and icons, `*Soft` for the background, `*Border` for the border. Badges, alerts, toasts and diffs take them, e.g. `bg-lightSuccessSoft text-lightSuccess border-lightSuccessBorder`.
1111
- Gray ground, white surfaces, 1px hairline borders. Shadows only on layers that float above the page: popovers, menus, dialogs.
12-
- One ink (near-black) primary button for the main action of a screen, everything else is `secondary`, destructive actions are `danger`. The accent color is the `primary` token: links, focus rings, selection, active states.
12+
- One ink (near-black) primary button for the main action of a screen, everything else is `secondary`, destructive actions are `danger`. The accent color is the `primary` token: links, focus rings, selection, active states. An app which wants its brand color on the primary button aliases the `Buttons*` tokens to `Primary` in `customization.styles` (see the branding docs); core and plugins keep styling a button with the `Buttons*` tokens, never with `Primary`.
1313
- Fonts are Geist and Geist Mono (`@fontsource-variable/*`, OFL), the default radius is 6px (`rounded-default`).
1414
- Controls are 34px high: buttons, icon buttons, inputs, selects, date pickers and the header controls (theme toggle, avatar, plugin buttons). Small buttons (`size="sm"`) and the pagination of tables are 28px.
1515
- Focus ring is 3px of the `FocusRing` token. Buttons, checkboxes and toggles show it only on keyboard focus (`focus:outline-none focus-visible:ring-[3px]`), so a mouse click leaves no ring. Text fields show it on any focus (`focus:ring-[3px]`), the user types there next. A clickable element is a `<button>`, not a `<span>` or `<div>` with `@click`, so it gets keyboard focus at all.

‎adminforth/modules/styleGenerator.ts‎

Lines changed: 32 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -314,34 +314,40 @@ export class StylesGenerator {
314314
return plainCustomStyles;
315315
}
316316

317-
private changeAlias(str:any, mergedStyles:any){
318-
const {aliasMatch,opacityMatch,darkenMatch,lightenMatch, inverseMatch} = parseColorForAliases(str);
317+
// a token may alias a token which is itself an alias further down the list, so the target is resolved first;
318+
// `chain` is the tokens being resolved above this one and catches a cycle such as a -> b -> a
319+
private resolveColor(key: string, colors: Record<string, string>, chain: string[] = []): string {
320+
const { aliasMatch, opacityMatch, darkenMatch, lightenMatch, inverseMatch } = parseColorForAliases(colors[key]);
319321
if (!aliasMatch) {
320-
return str;
321-
} else {
322-
const alias = aliasMatch[1];
323-
let opacity = opacityMatch ? parseFloat(opacityMatch[1]) : 1;
324-
const color = mergedStyles[alias];
325-
if (darkenMatch) {
326-
return darkenRGBA(createRGBA(color, opacity))
327-
}
328-
if (lightenMatch){
329-
return lightenRGBA(createRGBA(color, opacity))
330-
}
331-
if (inverseMatch){
332-
return inverseRGBA(createRGBA(color, opacity))
333-
}
334-
return createRGBA(color, opacity);
322+
return colors[key];
335323
}
336-
}
337-
338-
mergeStyles() {
339-
let mergedStyles = deepMerge(this.defaultStyles, this.generatePlainStyles(this.styleConfig));
340-
let colors = mergedStyles.colors;
341-
Object.entries(colors).forEach(([key,value])=>{
324+
const alias = aliasMatch[1];
325+
if (!(alias in colors)) {
326+
throw new Error(`Styles color ${key} aliases unknown token ${alias}`);
327+
}
328+
if (alias === key || chain.includes(alias)) {
329+
throw new Error(`Styles color alias cycle: ${[...chain, key, alias].join(' -> ')}`);
330+
}
331+
const opacity = opacityMatch ? parseFloat(opacityMatch[1]) : 1;
332+
const color = createRGBA(this.resolveColor(alias, colors, [...chain, key]), opacity);
333+
if (darkenMatch) {
334+
return darkenRGBA(color);
335+
}
336+
if (lightenMatch) {
337+
return lightenRGBA(color);
338+
}
339+
if (inverseMatch) {
340+
return inverseRGBA(color);
341+
}
342+
return color;
343+
}
342344

343-
colors[key] = this.changeAlias(value,colors)
344-
})
345+
mergeStyles() {
346+
const mergedStyles = deepMerge(this.defaultStyles, this.generatePlainStyles(this.styleConfig));
347+
const colors = mergedStyles.colors;
348+
Object.keys(colors).forEach((key) => {
349+
colors[key] = this.resolveColor(key, colors);
350+
});
345351
return mergedStyles;
346352
}
347-
}
353+
}

‎adminforth/spa/src/afcl/Button.vue‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
:disabled="props.disabled"
66
:class="buttonClasses"
77
>
8-
<Spinner v-if="props.loader" class="w-4 h-4 text-lightButtonsText dark:text-darkButtonsText fill-lightPrimary dark:fill-darkPrimary" />
8+
<Spinner v-if="props.loader" class="w-4 h-4" />
99
<!-- the spinner takes the place of the icon in a square button -->
1010
<slot v-if="!(props.iconOnly && props.loader)"></slot>
1111
</button>

‎adminforth/spa/src/afcl/Spinner.vue‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
<template>
2-
<div role="status">
3-
<svg aria-hidden="true" class="text-lightProgressBarUnfilledColor animate-spin dark:text-darkProgressBarUnfilledColor fill-lightPrimary dark:fill-darkPrimary" viewBox="0 0 100 101" fill="none" xmlns="http://www.w3.org/2000/svg">
4-
<path d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z" fill="currentColor"/>
5-
<path d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z" fill="currentFill"/>
2+
<!-- accent arc on a quiet track; inside an AFCL button both are drawn in the text color of the button (index.scss) -->
3+
<div role="status" class="af-spinner">
4+
<svg aria-hidden="true" class="animate-spin" viewBox="0 0 100 101" fill="none" xmlns="http://www.w3.org/2000/svg">
5+
<path class="af-spinner-track fill-lightProgressBarUnfilledColor dark:fill-darkProgressBarUnfilledColor" d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"/>
6+
<path class="af-spinner-arc fill-lightPrimary dark:fill-darkPrimary" d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"/>
67
</svg>
78
<span class="sr-only">{{ $t('Loading...') }}</span>
89
</div>

‎adminforth/spa/src/components/ThreeDotsMenu.vue‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@
5757
/>
5858
<Spinner
5959
v-if="actionLoadingStates[action.id!]"
60-
class="w-4 h-4 text-lightSkeletonBackgroundColor dark:text-darkSkeletonBackgroundColor fill-lightListTableText dark:fill-darkListTableText"
60+
class="w-4 h-4"
6161
/>
6262
{{ action.name }}
6363
</span>

‎adminforth/spa/src/index.scss‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@ body {
3333
box-shadow: 0 1px 1px rgb(24 24 27 / 0.05);
3434
}
3535

36+
// a spinner inside a button is drawn in the text color of the button, so it reads on ink and on a brand color alike
37+
.afcl-button .af-spinner, .afcl-link-button .af-spinner {
38+
.af-spinner-arc { fill: currentColor; }
39+
.af-spinner-track { fill: currentColor; opacity: 0.2; }
40+
}
41+
3642
*{
3743
-moz-user-select: none;
3844
}

‎adminforth/spa/src/views/ListView.vue‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@
6262
class="w-4 h-4 transition duration-75"></component>
6363
<Spinner
6464
v-if="bulkActionLoadingStates[action.id!]"
65-
class="w-4 h-4 text-lightSkeletonBackgroundColor dark:text-darkSkeletonBackgroundColor fill-lightListTableText dark:fill-darkListTableText"
65+
class="w-4 h-4"
6666
/>
6767
{{ `${action.label} (${checkboxes.length})` }}
6868
<div v-if="action.badge" class="inline-flex items-center h-[1.125rem] px-1.5 ml-1 rounded-full bg-purple-600 text-white text-[11px] font-semibold">
@@ -85,7 +85,7 @@
8585
class="w-4 h-4 transition duration-75"></component>
8686
<Spinner
8787
v-if="customActionLoadingStates[action.id!]"
88-
class="w-4 h-4 text-lightSkeletonBackgroundColor dark:text-darkSkeletonBackgroundColor fill-lightListTableText dark:fill-darkListTableText"
88+
class="w-4 h-4"
8989
/>
9090
{{ `${action.name} (${checkboxes.length})` }}
9191
</Button>

‎adminforth/spa/src/views/ShowView.vue‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
class="h-[2.125rem] text-lightListViewButtonText dark:text-darkListViewButtonText px-3"
1919
variant="secondary"
2020
>
21-
<Spinner v-if="isFetchingNextPage" class="w-4 h-4 text-lightSkeletonBackgroundColor dark:text-darkSkeletonBackgroundColor fill-lightListTableText dark:fill-darkListTableText" />
21+
<Spinner v-if="isFetchingNextPage" class="w-4 h-4" />
2222
{{ $t('Next') }}
2323
</LinkButton>
2424

@@ -44,7 +44,7 @@
4444
/>
4545
<Spinner
4646
v-if="actionLoadingStates[action.id!]"
47-
class="w-4 h-4 text-lightSkeletonBackgroundColor dark:text-darkSkeletonBackgroundColor fill-lightListTableText dark:fill-darkListTableText"
47+
class="w-4 h-4"
4848
/>
4949
{{ action.name }}
5050
</Button>

‎documentation/docs/tutorial/03-Customization/01-branding.md‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,45 @@ const admin = new AdminForth({
106106
Here is how it looks:
107107
![AdminForth Themes](image-10.png)
108108

109+
### Action buttons in the brand color
110+
111+
The main action button of a screen (Save, Create, every AFCL `Button` of the `primary` variant) is ink: near-black in the light theme, near-white in the dark one. The `primary` color goes to the accents around it: links, focus rings, checkboxes, toggles, progress bars. If you want these buttons in your brand color too, point the button tokens at `primary`:
112+
113+
```ts title='./index.ts'
114+
const admin = new AdminForth({
115+
...
116+
customization: {
117+
styles: {
118+
colors: {
119+
light: {
120+
primary: '#b400b8',
121+
buttons: {
122+
background: 'alias:lightPrimary',
123+
border: 'alias:lightPrimary',
124+
hover: 'alias:lightPrimary darken',
125+
borderHover: 'alias:lightPrimary darken',
126+
// black or white, whichever reads better on the primary color
127+
text: 'alias:lightPrimary inverse',
128+
},
129+
},
130+
dark: {
131+
buttons: {
132+
background: 'alias:darkPrimary',
133+
border: 'alias:darkPrimary',
134+
hover: 'alias:darkPrimary lighten',
135+
borderHover: 'alias:darkPrimary lighten',
136+
text: 'alias:darkPrimary inverse',
137+
},
138+
},
139+
}
140+
}
141+
},
142+
...
143+
});
144+
```
145+
146+
A value `alias:<token>` copies another token. The token name is the flattened key from [styles.ts](https://github.com/devforth/adminforth/blob/main/adminforth/modules/styles.ts): `light.buttons.background` is `lightButtonsBackground`. An alias takes modifiers: `opacity:0.3`, `darken`, `lighten` and `inverse`, which gives black or white depending on how bright the aliased color is.
147+
109148
## Single theme
110149

111150
If you want to enforce a consistent theme and disable the theme switcher, you can configure AdminForth to use only one theme variant.
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { StylesGenerator } from '../../adminforth/modules/styleGenerator';
2+
3+
function colorsOf(light: Record<string, unknown>) {
4+
return new StylesGenerator({ colors: { light } }).mergeStyles().colors;
5+
}
6+
7+
// a color which went through an alias chain is spaced differently, only the channels matter
8+
const compact = (color: string) => color.replace(/\s/g, '');
9+
10+
describe('customization.styles color aliases', () => {
11+
it('resolves an alias chain whichever order the tokens are listed in', () => {
12+
// lightCreateViewSaveButtonText aliases lightButtonsText, which comes later in styles.ts
13+
const colors = colorsOf({ primary: '#F59E0B', buttons: { text: 'alias:lightPrimary inverse' } });
14+
expect(colors.lightButtonsText).toBe('rgba(0,0,0,1)');
15+
expect(compact(colors.lightCreateViewSaveButtonText)).toBe('rgba(0,0,0,1)');
16+
expect(compact(colors.lightEditViewSaveButtonText)).toBe('rgba(0,0,0,1)');
17+
});
18+
19+
it('applies opacity, darken, lighten and inverse to the aliased color', () => {
20+
const colors = colorsOf({
21+
primary: '#1a56db',
22+
buttons: {
23+
background: 'alias:lightPrimary',
24+
hover: 'alias:lightPrimary darken',
25+
border: 'alias:lightPrimary lighten',
26+
text: 'alias:lightPrimary inverse',
27+
},
28+
});
29+
expect(colors.lightButtonsBackground).toBe('rgba(26,86,219,1)');
30+
expect(colors.lightButtonsHover).toBe('rgba(21, 69, 175, 1)');
31+
expect(colors.lightButtonsBorder).toBe('rgba(31, 103, 255, 1)');
32+
expect(colors.lightButtonsText).toBe('rgba(255,255,255,1)');
33+
expect(colors.lightButtonFocusRing).toBe('rgba(26,86,219,0.26)');
34+
});
35+
36+
it('keeps the ink buttons when only primary is set', () => {
37+
const colors = colorsOf({ primary: '#b400b8' });
38+
expect(colors.lightButtonsBackground).toBe('#18181B');
39+
expect(colors.lightCheckboxBgChecked).toBe('rgba(180,0,184,1)');
40+
});
41+
42+
it('rejects an alias cycle instead of looping', () => {
43+
expect(() => colorsOf({ primary: 'alias:lightButtonsBackground', buttons: { background: 'alias:lightPrimary' } }))
44+
.toThrow('lightPrimary -> lightButtonsBackground -> lightPrimary');
45+
expect(() => colorsOf({ primary: 'alias:lightPrimary' })).toThrow('lightPrimary -> lightPrimary');
46+
});
47+
48+
it('rejects an alias to a token which does not exist', () => {
49+
expect(() => colorsOf({ buttons: { background: 'alias:lightPrimry' } })).toThrow('unknown token lightPrimry');
50+
});
51+
});

0 commit comments

Comments
 (0)