Skip to content

Commit

Permalink
feat(angular): allow customizing avatar, icon and loading indicator f…
Browse files Browse the repository at this point in the history
…rom CSS (#291)

### 🎯 Goal

Allow customizing avatar, icon and loading indicator components from
CSS.

Corresponding GitHub issues:
- #84
- #84

It's only implemented for Angular, React can follow at it's own pace

### 🛠 Implementation details

This is a non-breaking change, the old CSS code remains, and can be used
by React and Angular@4.

#### Avatar

The `--str-chat__avatar-size` variable allows customizing the avatar
size

![Screenshot 2024-04-23 at 17 13
29](https://github.com/GetStream/stream-chat-css/assets/6690098/59f6dccf-25b9-4c61-95cc-36ddc31cc66a)

#### Icon

![Screenshot 2024-04-23 at 17 23
40](https://github.com/GetStream/stream-chat-css/assets/6690098/bb9da2b0-5431-4e32-b7d5-f0f8a3392301)

Why use fonts for icons? I generated a custom font from the icons we use
in chat UI components, there is one downside that I hate: the font needs
to be manually regenerated when we want to add a new icon (which is not
that often). However, as far as I know fonts provide the best
flexibility for setting size and color properties of icons. It's also
used by big libraries such as [Google Material
Icons](https://fonts.google.com/icons) so our integrators are probably
familiar with the concept.

As a comparison: in video the icons are also customizable from CSS, but
we use
[`mask-image`](https://github.com/GetStream/stream-video-js/blob/main/packages/styling/src/Icon/Icon-theme.scss#L16)
with encoded SVGs, the downside of that solution is that we have to
explicitly provide both `height` and `width`, which causes problems if
an integrator wants to use an icon with a different aspect-ratio than
the built-in icon.

#### Loading indicator

The `--str-chat__loading-indicator-size` allows setting avatar size from
CSS

![Screenshot 2024-04-24 at 18 15
00](https://github.com/GetStream/stream-chat-css/assets/6690098/2f89a466-cd53-466a-9ef6-688b11f26a23)

I wasn't able to move the loading indicator SVG to CSS, I had troubles
applying the color from the CSS variable to the stop color 🤷‍♀️

For the curious, here is the Angular upgrade guide with the breaking
changes:
![Screenshot 2024-04-24 at 18 23
45](https://github.com/GetStream/stream-chat-css/assets/6690098/ae947f85-2670-48af-9023-ea2db7dcdf4a)

![Screenshot 2024-04-24 at 18 24
03](https://github.com/GetStream/stream-chat-css/assets/6690098/ecf29085-822c-457c-960c-eefa60d8c010)

![Screenshot 2024-04-24 at 18 24
10](https://github.com/GetStream/stream-chat-css/assets/6690098/e9a743c0-076a-4e5c-afc7-f76fb7270e68)



### 🎨 UI Changes

No UI changes (hopefully 😅)

Make sure to test with both Angular and React (with both `MessageList`
and `VirtualizedMessageList` components) SDKs
  • Loading branch information
szuperaz committed Apr 25, 2024
1 parent 04e110d commit b4d1658
Show file tree
Hide file tree
Showing 35 changed files with 504 additions and 70 deletions.
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,15 @@ We've recently closed a [$38 million Series B funding round](https://techcrunch.
Our APIs are used by more than a billion end-users, and you'll have a chance to make a huge impact on the product within a team of the strongest engineers all over the world.

Check out our current openings and apply via [Stream's website](https://getstream.io/team/#jobs).

## Icons - for Stream Developers

- The icons for the UI components can be exported from [Figma](https://www.figma.com/files/project/42134328/SDK-Teams-support-files?fuid=1038443988589634784)
- Icons are used as fonts, the font files are located in `src/assets/icons`
- If you need to change icons you have to regenerate the icon fonts:

1. Go to [https://fontello.com/](https://fontello.com/)
2. Upload the `svg` font from `src/assets/icons`
3. Edit the font
4. Set the font name to `stream-chat-icons` and the CSS prefix to `str-chat__icon--`
5. Download the font, and copy the content of the `font` folder to `src/assets/icons`, and copy the mapping from `css/stream-chat-icons.css` to `src/v2/Icon/Icon-layout.scss`
73 changes: 73 additions & 0 deletions docs/theming/introduction.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,79 @@ To solve this we also have to set the text color for the link attachment compone

<img src={MessageCustomColor2Screenshot} width='500' />

### Custom icons

<SDKSpecific name="angular">

#### From CSS

Starting from stream-chat-angular@5 it's possible to customize icons from CSS.

Here is an example using the [Google Material Icon library](https://fonts.google.com/icons) to override the send icon:

```scss
// Import the icon library you want to use
@import url('https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200');

// Override the send icon
.str-chat__icon--send:before {
font-family: 'Material Symbols Outlined';
content: '\e163';
}
```

It's also possible to use image files for icons:

```scss
.str-chat__icon--send {
&:before {
display: none;
}
// Link to your image file, or encode the image inline
content: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgLTk2MCA5NjAgOTYwIiB3aWR0aD0iMjQiPjxwYXRoIGQ9Ik0xMjAtMTYwdi02NDBsNzYwIDMyMC03NjAgMzIwWm04MC0xMjAgNDc0LTIwMC00NzQtMjAwdjE0MGwyNDAgNjAtMjQwIDYwdjE0MFptMCAwdi00MDAgNDAwWiIvPjwvc3ZnPg==');
}
```

The full list of icons used by the SDK can be found [here](https://github.com/GetStream/stream-chat-css/blob/main/src/v2/styles/Icon/Icon-layout.scss).

You can also change the size and color of the icons:

```scss
.str-chat__icon--attach {
--str-chat-icon-color: green; // Only works for font icons
--str-chat-icon-height: 60px;
}
```

#### From HTML template

If you're using stream-chat-angular@4 or an older version, or CSS customizations are not enough, you can completely replace the built-in icon component with your own using the [`CustomTemplatesService`](../../services/CustomTemplatesService/#icontemplate).

You can find a working example in the [customization sample app](https://github.com/GetStream/stream-chat-angular/blob/master/projects/customizations-example/src/app/icon/icon.component.ts).

If the default rules set by the stream-chat-angular stylesheets not enough to set the size and color of your custom icons, you can rely on the `--str-chat-icon-color`, `--str-chat-icon-height` and `--str-chat-icon-width` variables:

```
.my-custom-send-icon {
svg {
height: var(--str-chat-icon-height);
width: var(--str-chat-icon-width);

path {
fill: var(--str-chat-icon-color);
}
}
}
```

</SDKSpecific>

<SDKSpecific name="react">

TODO

</SDKSpecific>

### CSS overrides

If you'd like to add customizations that are not supported by CSS variables, you can override parts of the default CSS:
Expand Down
145 changes: 97 additions & 48 deletions scripts/output.ts
Original file line number Diff line number Diff line change
@@ -1,46 +1,69 @@
import dedent from 'dedent';
import prettier from 'prettier';
import type { VariableGroup, VariableInfo } from './parser';
import * as packagejson from '../package.json'
import * as packagejson from '../package.json';

type Column = {name: string, key: keyof VariableInfo | 'usedIn', type: 'code' | 'text' | 'values'};
type Group = {name: string, regexp: RegExp, columns: Column[], definedIn?: Function};
type Column = {
name: string;
key: keyof VariableInfo | 'usedIn';
type: 'code' | 'text' | 'values';
};
type Group = { name: string; regexp: RegExp; columns: Column[]; definedIn?: Function };

const row = (v: VariableInfo, group: Group) => {
const usedIn = [...v.referencedIn].map(componentThemeLink).join(', ');
const info = {...v, usedIn};
return dedent`${group.columns.map(c => `| ${getColumn(c, info)}`).join('')}|`
const info = { ...v, usedIn };
return dedent`${group.columns.map((c) => `| ${getColumn(c, info)}`).join('')}|`;
};

const getColumn = (column: Column, info: VariableInfo & {usedIn?: string}) => {
const getColumn = (column: Column, info: VariableInfo & { usedIn?: string }) => {
if (column.type === 'values') {
return getValuesColumn(info);
} else {
return getTextOrCodeColumn(column, info);
}
}

const getValuesColumn = (info: VariableInfo & {usedIn?: string}) => {
return `<table>${info.values.map(v => `<tr><th>\`${v.scope}\`</th></tr><tr><td>\`${v.value}\`</td></tr>`).join('')}</table>`;
}

const getTextOrCodeColumn = (column: Column, info: VariableInfo & {usedIn?: string}) => {
return `${column.type === 'code' ? '\`' : ''}${info[column.key] || ''}${column.type === 'code' ? '\`' : ''}`
}

export const getGlobalVariablesOutput = (data: Map<string, VariableInfo>, type: 'theme' | 'layout') => {
const nameColumn: Column = {name: 'Name', key: 'name', type: 'code'};
const valueColumn: Column = {name: 'Value(s)', key: 'values', type: 'values'};
const descriptionColumn: Column = {name: 'Description', key: 'description', type: 'text'};
const usedInColumn: Column = {name: 'Used in', key: 'usedIn', type: 'text'}

};

const getValuesColumn = (info: VariableInfo & { usedIn?: string }) => {
return `<table>${info.values
.map((v) => `<tr><th>\`${v.scope}\`</th></tr><tr><td>\`${v.value}\`</td></tr>`)
.join('')}</table>`;
};

const getTextOrCodeColumn = (column: Column, info: VariableInfo & { usedIn?: string }) => {
return `${column.type === 'code' ? '`' : ''}${info[column.key] || ''}${
column.type === 'code' ? '`' : ''
}`;
};

export const getGlobalVariablesOutput = (
data: Map<string, VariableInfo>,
type: 'theme' | 'layout',
) => {
const nameColumn: Column = { name: 'Name', key: 'name', type: 'code' };
const valueColumn: Column = { name: 'Value(s)', key: 'values', type: 'values' };
const descriptionColumn: Column = { name: 'Description', key: 'description', type: 'text' };
const usedInColumn: Column = { name: 'Used in', key: 'usedIn', type: 'text' };

const groups = [
{name: 'Colors', regexp: /color/, columns: [nameColumn, valueColumn, descriptionColumn, usedInColumn]},
{name: 'Typography', regexp: /(__font|-text)/, columns: [nameColumn, valueColumn, descriptionColumn, usedInColumn]},
{name: 'Spacing', regexp: /spacing/, columns: [nameColumn, valueColumn, descriptionColumn]},
{name: 'Radius', regexp: /__border-radius/, columns: [nameColumn, valueColumn, descriptionColumn, usedInColumn]},
{name: 'Others', regexp: /.*/, columns: [nameColumn, valueColumn, descriptionColumn]}
]
{
name: 'Colors',
regexp: /color/,
columns: [nameColumn, valueColumn, descriptionColumn, usedInColumn],
},
{
name: 'Typography',
regexp: /(__font|-text)/,
columns: [nameColumn, valueColumn, descriptionColumn, usedInColumn],
},
{ name: 'Spacing', regexp: /spacing/, columns: [nameColumn, valueColumn, descriptionColumn] },
{
name: 'Radius',
regexp: /__border-radius/,
columns: [nameColumn, valueColumn, descriptionColumn, usedInColumn],
},
{ name: 'Others', regexp: /.*/, columns: [nameColumn, valueColumn, descriptionColumn] },
];

const variablesByGroups: Map<Group, string[]> = new Map();

Expand All @@ -57,42 +80,58 @@ export const getGlobalVariablesOutput = (data: Map<string, VariableInfo>, type:
});

let output = '';
groups.forEach(group => {
groups.forEach((group) => {
if (variablesByGroups.get(group)) {
const header = group.columns.map(c => `| ${c.name}`).join('') + `| \n` + group.columns.map(() => '|-').join('') + `|`;
const header =
group.columns.map((c) => `| ${c.name}`).join('') +
`| \n` +
group.columns.map(() => '|-').join('') +
`|`;
output += dedent`
### ${group.name}
${header}
${variablesByGroups.get(group)!.join('\n')}\n\n`;
}
});

output += `All global ${type} variables are defined in: [https://github.com/GetStream/stream-chat-css/tree/v${packagejson.version}/src/v2/styles/_global-${type}-variables.scss](https://github.com/GetStream/stream-chat-css/tree/v${packagejson.version}/src/v2/styles/_global-${type}-variables.scss)\n\n`
output += `All global ${type} variables are defined in: [https://github.com/GetStream/stream-chat-css/tree/v${packagejson.version}/src/v2/styles/_global-${type}-variables.scss](https://github.com/GetStream/stream-chat-css/tree/v${packagejson.version}/src/v2/styles/_global-${type}-variables.scss)\n\n`;

return format(output);
};

export const getComponentVariablesOutput = (data: Map<string, VariableInfo>) => {
const nameColumn: Column = {name: 'Name', key: 'name', type: 'code'};
const valueColumn: Column = {name: 'Value(s)', key: 'values', type: 'values'};
const descriptionColumn: Column = {name: 'Description', key: 'description', type: 'text'};
const nameColumn: Column = { name: 'Name', key: 'name', type: 'code' };
const valueColumn: Column = { name: 'Value(s)', key: 'values', type: 'values' };
const descriptionColumn: Column = { name: 'Description', key: 'description', type: 'text' };

const subgroupDefinitions = [
{name: 'Theme variables', regexp: /color|border|box-shadow|overlay|background/, columns: [nameColumn, valueColumn, descriptionColumn], definedIn: componentThemeLink},
{name: 'Layout variables', regexp: /.*/, columns: [nameColumn, valueColumn, descriptionColumn], definedIn: componentLayoutLink},
{
name: 'Theme variables',
regexp: /color|border|box-shadow|overlay|background/,
columns: [nameColumn, valueColumn, descriptionColumn],
definedIn: componentThemeLink,
},
{
name: 'Layout variables',
regexp: /.*/,
columns: [nameColumn, valueColumn, descriptionColumn],
definedIn: componentLayoutLink,
},
];

const componentsGroups: Map<VariableGroup, Map<Group, string[]>> = new Map();
data.forEach((v) => {
const variableGroup = v.definedIn;
if (variableGroup) {
let existingVariableGroup = Array.from(componentsGroups.keys()).find(g => g.componentName === variableGroup.componentName);
let existingVariableGroup = Array.from(componentsGroups.keys()).find(
(g) => g.componentName === variableGroup.componentName,
);
if (!existingVariableGroup) {
componentsGroups.set(variableGroup, new Map());
existingVariableGroup = variableGroup;
}
const componentGroup = componentsGroups.get(existingVariableGroup)!;
const subgroup = subgroupDefinitions.find(subgroup => subgroup.regexp.test(v.name));
const subgroup = subgroupDefinitions.find((subgroup) => subgroup.regexp.test(v.name));
if (subgroup) {
if (!componentGroup.get(subgroup)) {
componentGroup.set(subgroup, []);
Expand All @@ -103,21 +142,31 @@ export const getComponentVariablesOutput = (data: Map<string, VariableInfo>) =>
});

let output = '';
Array.from(componentsGroups.entries()).forEach(([variableGroup, variablesBySubgroups]) => {
output += dedent`
## ${variableGroup.componentName}${variableGroup.sdkRestriction ? ` - Only available in ${variableGroup.sdkRestriction} SDK` : ''}\n`;

subgroupDefinitions.forEach(group => {
if (variablesBySubgroups.get(group)) {
const header = group.columns.map(c => `| ${c.name}`).join('') + `| \n` + group.columns.map(() => '|-').join('') + `|`;
output += dedent`
Array.from(componentsGroups.entries())
.sort((e1, e2) => e1[0].componentName.localeCompare(e2[0].componentName))
.forEach(([variableGroup, variablesBySubgroups]) => {
output += dedent`
## ${variableGroup.componentName}${
variableGroup.sdkRestriction
? ` - Only available in ${variableGroup.sdkRestriction} SDK`
: ''
}\n`;

subgroupDefinitions.forEach((group) => {
if (variablesBySubgroups.get(group)) {
const header =
group.columns.map((c) => `| ${c.name}`).join('') +
`| \n` +
group.columns.map(() => '|-').join('') +
`|`;
output += dedent`
### ${group.name}
${header}
${variablesBySubgroups.get(group)!.join('\n')}\n
Defined in: ${group.definedIn(variableGroup.componentName)}\n\n`;
}
}
});
});
});

return format(output);
};
Expand Down
28 changes: 20 additions & 8 deletions scripts/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,13 @@ import CSS from 'tree-sitter-css';
type SDK = 'Angular' | 'React';
type ComponentName = string;
export type VariableGroup = {
componentName: ComponentName,
componentName: ComponentName;
sdkRestriction?: SDK;
};

export type VariableInfo = {
name: string;
values: {scope: string, value: string}[];
values: { scope: string; value: string }[];
theme?: string;
description?: string;
definedIn?: VariableGroup;
Expand Down Expand Up @@ -52,16 +52,24 @@ export const extractVariables = (fromGlob: string, dependencies?: Map<string, Va
sdkRestriction = matches[0] as SDK;
}
}
const variableGroup = {componentName, sdkRestriction};
ruleSet.descendantsOfType('declaration').forEach(node => {
const variableGroup = { componentName, sdkRestriction };
ruleSet.descendantsOfType('declaration').forEach((node) => {
const identifier = node.text;
if (identifier.startsWith('--str-chat')) {
const currentVariable = extractVariableInfo(node, variableGroup, ruleSet.firstChild?.text || '');
const currentVariable = extractVariableInfo(
node,
variableGroup,
ruleSet.firstChild?.text || '',
);
const seenVariable = componentVariables.get(currentVariable.name);
if (!seenVariable) {
// we see this variable for a very first time, store it in the map
componentVariables.set(currentVariable.name, currentVariable);
} else {
if (!seenVariable.description && currentVariable.description) {
seenVariable.definedIn = currentVariable.definedIn;
seenVariable.description = currentVariable.description;
}
seenVariable.values.push(...currentVariable.values);
}
}
Expand All @@ -87,7 +95,11 @@ export const extractVariables = (fromGlob: string, dependencies?: Map<string, Va
return componentVariables;
};

const extractVariableInfo = (node: SyntaxNode, variableGroup: VariableGroup, scope: string): VariableInfo => {
const extractVariableInfo = (
node: SyntaxNode,
variableGroup: VariableGroup,
scope: string,
): VariableInfo => {
// nodes in format: <name>: <value-1> <value-2> ... ;
const [name, _, ...declValues] = node.children;
const value = declValues
Expand All @@ -101,7 +113,7 @@ const extractVariableInfo = (node: SyntaxNode, variableGroup: VariableGroup, sco
const theme = detectTheme(node);
return {
name: name.text.trim(),
values: [{scope: scope.replace(/\n/g, ''), value}],
values: [{ scope: scope.replace(/\n/g, ''), value }],
theme,
description,
definedIn: variableGroup,
Expand All @@ -119,7 +131,7 @@ const extractComment = (node: SyntaxNode) => {
} else {
return undefined;
}
}
};

const detectTheme = (node: SyntaxNode) => {
let theme = undefined;
Expand Down
Binary file added src/assets/icons/stream-chat-icons.eot
Binary file not shown.
Loading

0 comments on commit b4d1658

Please sign in to comment.