create vulcan:ui-material, update to Apollo2 RR4

This commit is contained in:
eric-burel 2019-03-15 10:11:56 +01:00
parent a743022545
commit b7b872591a
21 changed files with 217 additions and 479 deletions

View file

@ -6,7 +6,7 @@ import { STATES } from '../../helpers.js';
class AccountsEnrollAccount extends PureComponent {
componentDidMount() {
const token = this.props.params.token;
const token = this.props.match.params.token;
Accounts._loginButtonsSession.set('enrollAccountToken', token);
}

View file

@ -1,15 +0,0 @@
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.js]
trim_trailing_whitespace = false
[*.md]
trim_trailing_whitespace = false

View file

@ -1,86 +0,0 @@
{
"extends": [
"eslint:recommended",
//"airbnb",
"plugin:meteor/recommended",
"plugin:react/recommended"
],
"parser": "babel-eslint",
"parserOptions": {
"allowImportExportEverywhere": true,
"ecmaVersion": 6,
"sourceType": "module"
},
"rules": {
"babel/generator-star-spacing": 0,
"babel/new-cap": [1, {
"capIsNewExceptions": [
"Optional",
"OneOf",
"Maybe",
"MailChimpAPI",
"Juice",
"Run",
"AppComposer",
"Query",
"InArray"
]
}],
"babel/array-bracket-spacing": 0,
"babel/object-curly-spacing": 0,
"babel/object-shorthand": 0,
"babel/arrow-parens": 0,
"babel/no-await-in-loop": 1,
"comma-dangle": 0,
"key-spacing": 0,
"no-extra-boolean-cast": 0,
"no-undef": 1,
"no-unused-vars": [1, {
"vars": "all",
"args": "none",
"varsIgnorePattern": "React|PropTypes|Component"
}],
"react/prop-types": 0,
"react/display-name": 0,
"meteor/audit-argument-checks": 0,
"meteor/no-session": 0,
"no-case-declarations": 0,
"no-console": 0,
"semi": "error",
"quotes": [
1,
"single"
]
},
"env": {
"browser": true,
"commonjs": true,
"es6": true,
"meteor": true,
"node": true
},
"plugins": [
"babel",
"meteor",
"react",
"jsx-a11y"
],
"settings": {
"import/resolver": {
"meteor": {
"paths": [
"/usr/local/share/global_modules"
],
"moduleDirectory": [
"node_modules",
"packages"
]
}
}
},
"root": true,
"globals": {
"param": true,
"returns": true
}
}

View file

@ -1,3 +1,6 @@
npm-debug.log
node_modules
.idea/workspace.xml
### eslint-config
.eslintrc

View file

@ -4,31 +4,40 @@ import { Components, registerComponent } from 'meteor/vulcan:core';
import { intlShape } from 'meteor/vulcan:i18n';
import EditIcon from 'mdi-material-ui/Pencil';
const EditButton = ({
collection,
document,
color = 'default',
variant,
triggerClasses,
buttonClasses,
...props
}, { intl }) => (
const EditButton = (
{
collection,
document,
color = 'default',
variant,
triggerClasses,
buttonClasses,
showRemove,
...props
},
{ intl }
) => (
<Components.ModalTrigger
classes={triggerClasses}
component={<Components.TooltipIconButton titleId="datatable.edit"
icon={<EditIcon/>}
color={color}
variant={variant}
classes={buttonClasses}
/>}
component={
<Components.TooltipIconButton
titleId="datatable.edit"
icon={<EditIcon />}
color={color}
variant={variant}
classes={buttonClasses}
/>
}
>
<Components.EditForm collection={collection} document={document} {...props}/>
<Components.EditForm
collection={collection}
document={document}
showRemove={showRemove}
{...props}
/>
</Components.ModalTrigger>
);
EditButton.propTypes = {
collection: PropTypes.object.isRequired,
document: PropTypes.object.isRequired,
@ -36,27 +45,32 @@ EditButton.propTypes = {
variant: PropTypes.string,
triggerClasses: PropTypes.object,
buttonClasses: PropTypes.object,
showRemove: PropTypes.bool
};
EditButton.contextTypes = {
intl: intlShape
};
EditButton.displayName = 'EditButton';
registerComponent('EditButton', EditButton);
/*
EditForm Component
*/
const EditForm = ({ collection, document, closeModal, options, successCallback, removeSuccessCallback,...props }) => {
const EditForm = ({
collection,
document,
closeModal,
options,
successCallback,
removeSuccessCallback,
showRemove,
...props
}) => {
const success = successCallback
? () => {
successCallback();
@ -70,17 +84,17 @@ const EditForm = ({ collection, document, closeModal, options, successCallback,
closeModal();
}
: closeModal;
return (
<Components.SmartForm
{...props}
collection={collection}
documentId={document && document._id}
showRemove={true}
successCallback={success}
removeSuccessCallback={remove}
/>
);
}
<Components.SmartForm
{...props}
collection={collection}
documentId={document && document._id}
showRemove={showRemove ? true : showRemove}
successCallback={success}
removeSuccessCallback={remove}
/>
);
};
registerComponent('EditForm', EditForm);

View file

@ -1,12 +1,15 @@
import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import { intlShape } from 'meteor/vulcan:i18n';
import { registerComponent } from 'meteor/vulcan:core';
import { registerComponent, Components } from 'meteor/vulcan:core';
import withStyles from '@material-ui/core/styles/withStyles';
import Dialog from '@material-ui/core/Dialog';
import DialogContent from '@material-ui/core/DialogContent';
import DialogTitle from '@material-ui/core/DialogTitle';
import DialogContent from '@material-ui/core/DialogContent';
import Button from '@material-ui/core/Button';
import Tooltip from '@material-ui/core/Tooltip';
import Close from 'mdi-material-ui/Close';
import classNames from 'classnames';
@ -17,27 +20,29 @@ const styles = theme => ({
button: {},
anchor: {},
dialog: {},
dialogPaper: {},
dialogTitle: {},
dialogPaper: {
overflowY: 'visible',
},
dialogTitle: {
padding: theme.spacing.unit * 4,
},
dialogContent: {
paddingTop: '4px',
},
dialogOverflow: {
overflowY: 'visible',
},
closeButton: {
position: 'absolute',
right: theme.spacing.unit,
top: theme.spacing.unit,
}
});
class ModalTrigger extends PureComponent {
constructor (props) {
super(props);
this.state = { modalIsOpen: false };
}
componentDidMount() {
if (this.props.action) {
this.props.action({
@ -50,11 +55,11 @@ class ModalTrigger extends PureComponent {
openModal = () => {
this.setState({ modalIsOpen: true });
};
closeModal = () => {
this.setState({ modalIsOpen: false });
};
render () {
const {
className,
@ -67,9 +72,9 @@ class ModalTrigger extends PureComponent {
children,
classes,
} = this.props;
const intl = this.context.intl;
const label = labelId ? intl.formatMessage({ id: labelId }) : this.props.label;
const title = titleId ? intl.formatMessage({ id: titleId }) : this.props.title;
const overflowClass = dialogOverflow && classes.dialogOverflow;
@ -83,35 +88,39 @@ class ModalTrigger extends PureComponent {
<Button className={classes.button} variant="contained" onClick={this.openModal}>{label}</Button>
:
<a className={classes.anchor} href="#" onClick={this.openModal}>{label}</a>;
const childrenComponent = typeof children.type === 'function' ?
React.cloneElement(children, { closeModal: this.closeModal }) :
children;
return (
<span className={classNames('modal-trigger', classes.root, className)}>
{triggerComponent}
<Dialog className={classNames(dialogClassName)}
open={this.state.modalIsOpen}
onClose={this.closeModal}
fullWidth={true}
classes={{ paper: classNames(classes.dialogPaper, overflowClass) }}
>
{
title &&
<DialogTitle className={classes.dialogTitle}>{title}</DialogTitle>
}
<DialogContent className={classNames(classes.dialogContent, overflowClass)}>
{childrenComponent}
</DialogContent>
</Dialog>
<Dialog className={classNames(dialogClassName)}
open={this.state.modalIsOpen}
onClose={this.closeModal}
fullWidth={true}
classes={{ paper: classes.paper }}
>
<DialogTitle className={classes.dialogTitle}>
{title}
<Components.Button iconButton aria-label="Close" className={classes.closeButton} onClick={this.closeModal}>
<Tooltip title={intl.formatMessage({ id: 'modal.close' })}>
<Close />
</Tooltip>
</Components.Button>
</DialogTitle>
<DialogContent className={classes.dialogContent}>
<Components.ErrorCatcher>
{childrenComponent}
</Components.ErrorCatcher>
</DialogContent>
</Dialog>
</span>
);
}

View file

@ -118,7 +118,7 @@ FormComponentInner.propTypes = {
showCharsRemaining: PropTypes.bool.isRequired,
charsRemaining: PropTypes.number,
charsCount: PropTypes.number,
max: PropTypes.number,
max: PropTypes.oneOfType([PropTypes.number, PropTypes.instanceOf(Date)]),
formInput: PropTypes.func.isRequired,
};

View file

@ -1,205 +0,0 @@
import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import { Components, replaceComponent, instantiateComponent, } from 'meteor/vulcan:core';
import Users from 'meteor/vulcan:users';
import withStyles from '@material-ui/core/styles/withStyles';
import Collapse from '@material-ui/core/Collapse';
import Typography from '@material-ui/core/Typography';
import Paper from '@material-ui/core/Paper';
import ExpandLessIcon from 'mdi-material-ui/ChevronUp';
import ExpandMoreIcon from 'mdi-material-ui/ChevronDown';
import classNames from 'classnames';
const styles = theme => ({
root: {
minWidth: '320px'
},
paper: {
padding: theme.spacing.unit * 3
},
subtitle1: {
display: 'flex',
alignItems: 'center',
paddingLeft: theme.spacing.unit / 2,
marginTop: theme.spacing.unit * 5,
marginBottom: theme.spacing.unit,
color: theme.palette.primary[500],
},
collapsible: {
cursor: 'pointer',
},
label: {},
toggle: {
'& svg': {
width: 21,
height: 21,
display: 'block',
}
},
container: {
paddingLeft: 4,
paddingRight: 4,
marginLeft: -4,
marginRight: -4,
},
entered: {
overflow: 'visible',
},
});
class FormGroup extends PureComponent {
constructor (props) {
super(props);
this.isAdmin = props.name === 'admin';
this.state = {
collapsed: props.startCollapsed || this.isAdmin,
};
}
toggle = () => {
const collapsible = this.props.collapsible || this.isAdmin;
if (!collapsible) return;
this.setState({
collapsed: !this.state.collapsed
});
};
renderHeading = () => {
const { classes, label } = this.props;
const collapsible = this.props.collapsible || this.isAdmin;
return (
<Typography className={classNames(classes.subtitle1, collapsible && classes.collapsible)}
variant="subtitle1"
onClick={this.toggle}
>
<div className={classes.label}>
{label}
</div>
{
collapsible &&
<div className={classes.toggle}>
{
this.state.collapsed
?
<ExpandMoreIcon/>
:
<ExpandLessIcon/>
}
</div>
}
</Typography>
);
};
// if at least one of the fields in the group has an error, the group as a whole has an error
hasErrors = () => _.some(this.props.fields, field => {
return !!this.props.errors.filter(error => error.path === field.path).length;
});
render () {
const {
name,
hidden,
classes,
currentUser,
} = this.props;
if (this.isAdmin && !Users.isAdmin(currentUser)) {
return null;
}
if (typeof hidden === 'function' ? hidden({ ...this.props }) : hidden) {
return null;
}
//do not display if no fields, no startComponent and no endComponent
if (!this.props.startComponent && !this.props.endComponent && !this.props.fields.length) {
return null;
}
const anchorName = name.split('.').length > 1 ? name.split('.')[1] : name;
const collapseIn = !this.state.collapsed || this.hasErrors();
const FormComponents = this.props.formComponents;
return (
<div className={classes.root}>
<a name={anchorName}/>
{
name === 'default'
?
null
:
this.renderHeading()
}
<Collapse classes={{ container: classes.container, entered: classes.entered }} in={collapseIn}>
<Paper className={classes.paper}>
{instantiateComponent(this.props.startComponent)}
{this.props.fields.map(field => (
<FormComponents.FormComponent
key={field.name}
disabled={this.props.disabled}
{...field}
errors={this.props.errors}
throwError={this.props.throwError}
currentValues={this.props.currentValues}
updateCurrentValues={this.props.updateCurrentValues}
deletedValues={this.props.deletedValues}
addToDeletedValues={this.props.addToDeletedValues}
clearFieldErrors={this.props.clearFieldErrors}
formType={this.props.formType}
currentUser={this.props.currentUser}
formComponents={FormComponents}
/>
))}
{instantiateComponent(this.props.endComponent)}
</Paper>
</Collapse>
</div>
);
}
}
FormGroup.propTypes = {
name: PropTypes.string,
label: PropTypes.string,
order: PropTypes.number,
hidden: PropTypes.bool,
fields: PropTypes.array,
collapsible: PropTypes.bool,
startCollapsed: PropTypes.bool,
updateCurrentValues: PropTypes.func,
startComponent: PropTypes.node,
endComponent: PropTypes.node,
currentUser: PropTypes.object,
};
replaceComponent('FormGroup', FormGroup, [withStyles, styles]);

View file

@ -0,0 +1,24 @@
import React from 'react';
import PropTypes from 'prop-types';
import { replaceComponent } from 'meteor/vulcan:core';
import Grid from '@material-ui/core/Grid';
import Typography from '@material-ui/core/Typography';
const FormNestedArrayLayout = ({ hasErrors, label, content }) => (
<div>
<Typography component="label" variant="caption" style={{ fontSize: 16 }}>
{label}
</Typography>
<div>{content}</div>
</div>
);
FormNestedArrayLayout.propTypes = {
hasErrors: PropTypes.bool,
label: PropTypes.node,
content: PropTypes.node,
};
replaceComponent({
name: 'FormNestedArrayLayout',
component: FormNestedArrayLayout,
});

View file

@ -1,24 +1,20 @@
import React from 'react';
import PropTypes from 'prop-types';
import { registerComponent } from 'meteor/vulcan:core';
import { FormattedMessage } from 'meteor/vulcan:i18n';
import { replaceComponent } from 'meteor/vulcan:core';
// import { FormattedMessage } from 'meteor/vulcan:i18n';
import withStyles from '@material-ui/core/styles/withStyles';
import Divider from '@material-ui/core/Divider';
const styles = theme => ({
divider: {
marginLeft: -24,
marginRight: -24,
// marginLeft: -24,
// marginRight: -24,
marginTop: 16,
marginBottom: 23,
},
});
const FormNestedDivider = ({ classes, label, addItem }) => <Divider className={classes.divider}/>;
const FormNestedDivider = ({ classes, label, addItem }) => <Divider className={classes.divider} />;
FormNestedDivider.propTypes = {
classes: PropTypes.object.isRequired,
@ -26,4 +22,4 @@ FormNestedDivider.propTypes = {
addItem: PropTypes.func,
};
registerComponent('FormNestedDivider', FormNestedDivider, [withStyles, styles]);
replaceComponent('FormNestedDivider', FormNestedDivider, [withStyles, styles]);

View file

@ -5,8 +5,8 @@ import { FormattedMessage } from 'meteor/vulcan:i18n';
import Grid from '@material-ui/core/Grid';
const FormNestedFoot = ({ label, addItem }) => (
<Grid container spacin={0} justify="flex-end">
<Components.Button color="primary" variant="fab" mini onClick={addItem} className="form-nested-add">
<Grid container spacing={0} justify="flex-end">
<Components.Button color="primary" iconButton onClick={addItem}>
<Components.IconAdd/>
</Components.Button>
</Grid>

View file

@ -73,11 +73,7 @@ const MuiRadioGroup = createReactClass({
options: PropTypes.array.isRequired
},
getInitialState: function () {
if (this.props.refFunction) {
this.props.refFunction(this);
}
},
getDefaultProps: function () {
return {

View file

@ -105,6 +105,7 @@ export default {
'description',
'clearField',
'regEx',
'allowedValues',
'mustComplete',
'renderComponent',
'formInput',

View file

@ -7,7 +7,7 @@ import './accounts/AccountsPasswordOrService';
import './accounts/AccountsSocialButtons';
import './bonus/LoadMore';
import './bonus/SearchInput';
// import './bonus/SearchInput';
import './bonus/TooltipIntl';
import './bonus/TooltipIconButton';
@ -20,10 +20,11 @@ import './core/NewButton';
import './forms/FormComponentInner';
import './forms/FormErrors';
import './forms/FormGroup';
//import './forms/FormGroup';
import './forms/FormGroupNone';
import './forms/FormGroupWithLine';
import './forms/FormNested';
import './forms/FormNestedArrayLayout';
import './forms/FormNestedDivider';
import './forms/FormNestedFoot';
import './forms/FormNestedHead';

View file

@ -3,10 +3,10 @@ import { addStrings } from 'meteor/vulcan:core';
addStrings('en', {
"search.search": "Search",
"search.clear": "Clear search",
"load_more.load_more": "Load more",
"load_more.loaded_count": "Loaded {count} of {totalCount}",
"load_more.loaded_all": "{totalCount, plural, =0 {No items} one {One item} other {# items}}",
'search.search': 'Search',
'search.clear': 'Clear search',
'load_more.load_more': 'Load more',
'load_more.loaded_count': 'Loaded {count} of {totalCount}',
'load_more.loaded_all': '{totalCount, plural, =0 {No items} one {One item} other {# items}}',
});

View file

@ -74,63 +74,64 @@ const styles = theme => {
};
class Layout extends React.Component {
state = {
isOpen: { sideNav: true }
};
toggle = (item, openOrClose) => {
const newState = { isOpen: {} };
newState.isOpen[item] = typeof openOrClose === 'string' ?
openOrClose === 'open' :
!this.state.isOpen[item];
this.setState(newState);
};
class Layout extends React.Component {
state = {
isOpen: { sideNav: true }
};
render = () => {
const routeName = Utils.slugify(this.props.currentRoute.name);
const classes = this.props.classes;
const isOpen = this.state.isOpen;
toggle = (item, openOrClose) => {
const newState = { isOpen: {} };
newState.isOpen[item] = typeof openOrClose === 'string' ?
openOrClose === 'open' :
!this.state.isOpen[item];
this.setState(newState);
};
return (
<div className={classNames(classes.root, 'wrapper', `wrapper-${routeName}`)}>
<div className={classes.appFrame}>
render = () => {
const routeName = Utils.slugify(this.props.currentRoute.name);
const classes = this.props.classes;
const isOpen = this.state.isOpen;
<Components.Header isSideNavOpen={isOpen.sideNav}
toggleSideNav={openOrClose =>
this.toggle('sideNav', openOrClose)} />
return (
<div className={classNames(classes.root, 'wrapper', `wrapper-${routeName}`)}>
<div className={classes.appFrame}>
<Drawer variant="persistent"
classes={{ paper: classes.drawerPaper, }}
open={isOpen.sideNav}
>
<AppBar className={classes.drawerHeader} elevation={4} square={true}>
<Toolbar>
</Toolbar>
</AppBar>
<Components.SideNavigation />
</Drawer>
<Components.Header isSideNavOpen={isOpen.sideNav}
toggleSideNav={openOrClose =>
this.toggle('sideNav', openOrClose)} />
<main className={classNames(classes.content, isOpen.sideNav && classes.mainShift)}>
{this.props.children}
</main>
<Drawer variant="persistent"
classes={{ paper: classes.drawerPaper, }}
open={isOpen.sideNav}
>
<AppBar className={classes.drawerHeader} elevation={4} square={true}>
<Toolbar>
</Toolbar>
</AppBar>
<Components.SideNavigation />
</Drawer>
<Components.FlashMessages />
<main className={classNames(classes.content, isOpen.sideNav && classes.mainShift)}>
{this.props.children}
</main>
<Components.FlashMessages />
</div>
</div>
</div>
);
);
};
}
Layout.propTypes = {
classes: PropTypes.object.isRequired,
children: PropTypes.node,
};
}
Layout.propTypes = {
classes: PropTypes.object.isRequired,
children: PropTypes.node,
};
Layout.displayName = 'Layout';
Layout.displayName = 'Layout';
replaceComponent('Layout', Layout, [withStyles, styles]);
replaceComponent('Layout', Layout, [withStyles, styles]);

View file

@ -1,7 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Components, registerComponent, withCurrentUser } from 'meteor/vulcan:core';
import { withRouter } from 'react-router-dom';
import { withRouter } from 'react-router';
import List from '@material-ui/core/List';
import ListItem from '@material-ui/core/ListItem';
import ListItemIcon from '@material-ui/core/ListItemIcon';
@ -38,15 +38,14 @@ class SideNavigation extends React.Component {
};
render () {
const currentUser = this.props.currentUser;
const classes = this.props.classes;
const { currentUser, classes, history } = this.props;
const isOpen = this.state.isOpen;
return (
<div className={classes.root}>
<List>
<ListItem button onClick={() => {this.props.history.push('/');}}>
<ListItem button onClick={() => {history.push('/');}}>
<ListItemIcon>
<HomeIcon/>
</ListItemIcon>
@ -69,14 +68,14 @@ class SideNavigation extends React.Component {
</ListItem>
<Collapse in={isOpen.admin} transitionduration="auto" unmountOnExit>
<ListItem button className={classes.nested}
onClick={() => {this.props.history.push('/admin');}}>
onClick={() => {browserHistory.push('/admin');}}>
<ListItemIcon>
<UsersIcon/>
</ListItemIcon>
<ListItemText inset primary="Users"/>
</ListItem>
<ListItem button className={classes.nested}
onClick={() => {this.props.history.push('/theme');}}>
onClick={() => {browserHistory.push('/theme');}}>
<ListItemIcon>
<ThemeIcon/>
</ListItemIcon>

View file

@ -1,8 +1,7 @@
import { addStrings } from 'meteor/vulcan:core';
addStrings('fr', {
"search.search": "Recherche",
"search.clear": "Effacer la recherche",
'search.search': 'Recherche',
'search.clear': 'Effacer la recherche',
'modal.close': 'Fermer',
});

View file

@ -1,8 +1,10 @@
import { addRoute } from 'meteor/vulcan:core';
addRoute({
name: 'theme',
path: '/theme',
componentName: 'ThemeStyles',
});
//Only create route on dev mode, not production.
if (Meteor.isDevelopment) {
addRoute({
name: 'theme',
path: '/theme',
componentName: 'ThemeStyles',
});
}

View file

@ -5,7 +5,6 @@ Package.describe({
documentation: 'README.md'
});
Package.onUse(function (api) {
api.versionsFrom('METEOR@1.6');
@ -23,4 +22,4 @@ Package.onUse(function (api) {
api.mainModule('client/main.js', 'client');
api.mainModule('server/main.js', 'server');
});
});

View file

@ -1,10 +1,10 @@
# erikdakoda:vulcan-material-ui 1.12.8_13
# vulcan:ui-material 1.12.8_13
Package initially created by [Erik Dakoda](https://github.com/ErikDakoda) ([`erikdakoda:vulcan-material-ui`](https://github.com/ErikDakoda/vulcan-material-ui))
Replacement for [Vulcan](http://vulcanjs.org/) components using [Material-UI](https://material-ui.com/).
This version has been tested against Vulcan 1.12.8 and Material-UI 3.1.0.
To give me feedback open an issue on GitHub or you can reach me on the [Vulcan Slack](https://vulcanjs.slack.com)
channel as erikdakoda.
There are some nice bonus features like a CountrySelect with autocomplete and theming.
@ -15,9 +15,9 @@ All components in vulcan:ui-bootstrap, vulcan:forms and vulcan:accounts have bee
To add vulcan-material-ui to an existing Vulcan project, enter the following:
``` sh
meteor add erikdakoda:vulcan-material-ui
meteor add vulcan:ui-material
meteor npm install --save @material-ui/core
meteor npm install --save @material-ui/core@3.1.0
meteor npm install --save react-jss
meteor npm install --save mdi-material-ui
meteor npm install --save react-autosuggest
@ -48,7 +48,7 @@ For an example theme see `modules/sampleTheme.js`. For a complete list of values
see the [MUI Default Theme](https://material-ui-next.com/customization/default-theme/).
Register your theme in the Vulcan environment by giving it a name: `registerTheme('MyTheme', theme);`.
You can have multiple themes registered and you can specify which one to use in your settings file using the `muiTheme` public setting.
You can have multiple themes registered and you can specify which one to use in your settings file using the `muiTheme` **public** setting.
In addition to the Material UI spec, I use a `utils` section in my themes where I place global variables for reusable styles.
For example the sample theme contains