Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

customization: CSS, String template, Row template #15

Merged
merged 7 commits into from
Jul 6, 2021
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,77 @@ The element also emits **events** as a user interacts with it. This is how you c
|`change` |`string` |Dispatched with every keystroke as the user types (not debounced).|
|`error` |`Error` |Dispatched if an error occures during the request (for example if the rate limit was exceeded.)|



## Customization

We did our best to design the element in such a way that it can be used as is in a lot of different contexts without needing to adjust how it looks. However, there certainly can be situations where customization is necessary. The element supports three different customization APIs:

1. Custom CSS (variables)
2. A string template as well as
3. A row template

### 1. Custom CSS

We use CSS variables for almost all properties that you would want to customize. This includes the font family, background or shadow of the input and the hover state for a result just to name a few. For a list of all available variables please check the source CSS file directly ([`/src/autocomplete/autocomplete.css`](src/autocomplete/autocomplete.css)).

You can adjust these variables by placing a `<style>` tag _inside_ the element, like so:

```html
<ge-autocomplete api_key="…">
<style>
:host {
--input-bg: salmon;
--input-color: green;
--loading-color: hotpink;
}
</style>
</ge-autocomplete>
```

**Important:** While it is technically possible to override the actual classnames the element uses internally, we do not consider those part of the public API. That means they can change without a new major version, which could break your customization. The CSS variables on the other hand are specifically meant to be customized, which is why they will stay consistent even if the internal markup changes.

If you would like to customize a property for which there is no variable we’d be happy to accept a pull-request or issue about it.

### 2. String Template

If you want to customize how a feature is turned into a string for rendering (in the results as well as the input field after it was selected), you can define a custom string template. This allows you to use the [lodash template language][_template] to access every property of the feature to build a custom string.
mxlje marked this conversation as resolved.
Show resolved Hide resolved

```html
<ge-autocomplete api_key="…">
<template string>
${feature.properties.name} (${feature.properties.id}, ${feature.properties.source})
</template>
</ge-autocomplete>
```

**Important:** Make sure to return a plain string here, no HTML. The reason for this is that this template will also be used in the input field itself after a result has been selected, which doesn’t support HTML.

### 3. Row Template

Similar to the string template mentioned above, you can use the row template to define how a single row in the results is rendered. The key here is that this supports full HTML:

```html
<ge-autocomplete api_key="…">
<template row>
<div class="custom-row ${feature.active ? 'custom-row--active' : null}">
<img src="/flags/${feature.properties.country_a.png" alt="${feature.country_a}">
mxlje marked this conversation as resolved.
Show resolved Hide resolved
<span>${feature.properties.label}</span>
</div>
</template>
</ge-autocomplete>
```

**Pro Tip™:** Use the `active` property to check if the current row is being hovered over or activated via arrow keys.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pro Tip™:

LOL 👍


The example above could render a little flag icon for the result’s country, for example. You can customize the styling by defining custom classes in the same way you would customize the CSS variables. It’s best to prefix your classes to avoid conflicts with internal classnames of the element.

The [lodash template language][_template] supports much more than just straight variables. Please refer to their docs directly to understand how it works. It’s pretty powerful.

[_template]: https://lodash.com/docs/4.17.15#template



## Example

Please see the `example` folder. You can follow the steps in the [**Development**](#development) section to run them directly, too.
Expand Down
67 changes: 67 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@
"@geocodeearth/core-js": "^0.0.7",
"downshift": "6.1.3",
"lodash.debounce": "^4.0.8",
"lodash.escape": "^4.0.1",
"lodash.template": "^4.5.0",
"lodash.unescape": "^4.0.1",
"react": "^17.0.2",
"react-dom": "^17.0.2"
},
Expand Down
54 changes: 38 additions & 16 deletions src/autocomplete/autocomplete.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import debounce from 'lodash.debounce'
import css from './autocomplete.css'
import strings from '../strings'
import { LocationMarker, Loading } from '../icons'
import escape from '../escape'

const emptyResults = {
text: '',
Expand All @@ -22,7 +23,9 @@ export default ({
onSelect: userOnSelectItem,
onChange: userOnChange,
onError: userOnError,
environment = window
environment = window,
rowTemplate,
stringTemplate
}) => {
const [results, setResults] = useState(emptyResults)
const [isLoading, setIsLoading] = useState(false)
Expand Down Expand Up @@ -104,7 +107,13 @@ export default ({
}

// turns an autocomplete result (feature) into a string
const itemToString = ({ properties: { label } }) => label
const itemToString = (feature) => {
if (typeof stringTemplate === 'function') {
return stringTemplate(escape(feature))
}

return feature.properties.label
}

// focus the input field if requested
useEffect(() => {
Expand Down Expand Up @@ -150,20 +159,33 @@ export default ({

<ol {...getMenuProps()} className={showResults ? 'results' : 'results-empty'}>
{showResults &&
results.features.map((item, index) => (
<li
className={
highlightedIndex === index
? 'result-item result-item-active'
: 'result-item'
}
key={item.properties.id}
{...getItemProps({ item, index })}
>
<LocationMarker className='result-item-icon' />
{itemToString(item)}
</li>
))}
results.features.map((item, index) => {
// render row with custom template, if available
// the feature itself is recursively escaped as we can’t guarantee safe data from the API
if (typeof rowTemplate === 'function') {
return <li
key={item.properties.id}
{...getItemProps({ item, index })}
dangerouslySetInnerHTML={{ __html: rowTemplate(escape({
...item,
active: highlightedIndex === index
})) }}
/>
} else {
return <li
className={
highlightedIndex === index
? 'result-item result-item-active'
: 'result-item'
}
key={item.properties.id}
{...getItemProps({ item, index })}
>
<LocationMarker className='result-item-icon' />
{itemToString(item)}
</li>
}
})}

<div className='attribution'>
©&nbsp;<a href="https://geocode.earth">Geocode Earth</a>,&nbsp;
Expand Down
18 changes: 18 additions & 0 deletions src/escape.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import _escape from 'lodash.escape'

// escape takes any value (primarily objects) and recursively escapes the values
const escape = (v) => {
if (typeof v === 'string') return _escape(v)
if (typeof v === 'number' || typeof v === 'boolean') return v
if (Array.isArray(v)) return v.map(l => escape(l))

return Object.keys(v).reduce(
(attrs, key) => ({
...attrs,
[key]: escape(v[key]),
}),
{}
)
}

export default escape
47 changes: 46 additions & 1 deletion src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import React, { useMemo } from 'react'
import ReactDOM from 'react-dom'
import Autocomplete from './autocomplete'
import compact from './compact'
import _template from 'lodash.template'
import _unescape from 'lodash.unescape'

const customElementName = 'ge-autocomplete'

Expand Down Expand Up @@ -130,12 +132,55 @@ class GEAutocomplete extends HTMLElement {
}

connectedCallback () {
this.importStyles()
this.importTemplates()
this.render()
}

// importStyles looks for a specific template tag inside the custom element
// and moves its content (expected to be a <style> tag) inside the Shadow DOM,
// which can be used to customize the styling of the component.
importStyles() {
const styles = this.querySelector('style')
if (styles === null) return

// appending the node somewhere else _moves_ automatically it so we don’t have
// to explicitly remove it
this.shadowRoot.appendChild(styles)
}

// importTemplates looks for custom <template> tags inside this custom element,
// parses their content as a lodash template and stores them to be passed on
// to the autocomplete component
importTemplates() {
const templates = {
stringTemplate: this.querySelector('template[string]'),
rowTemplate: this.querySelector('template[row]')
}

Object.keys(templates).forEach(k => {
const tmpl = templates[k]
if (tmpl === null) return

this[k] = _template(
_unescape(tmpl.innerHTML.trim()), // unescape is important for `<%` etc. lodash tags
{ variable: 'feature' } // namespace the passed in Feature as `feature` so missing keys don’t throw
)

// contrary to the way custom styles are handled above we remove the <template> when we’re done
// so it doesn’t hang around in the host document (not the Shadow DOM)
tmpl.remove()
})
}

render () {
ReactDOM.render(
<WebComponent {...this.props} host={this} />,
<WebComponent
{...this.props}
host={this}
stringTemplate={this.stringTemplate}
rowTemplate={this.rowTemplate}
/>,
this.shadowRoot
)
}
Expand Down