Skip to content

Commit

Permalink
Add SVG Sprite Module for icons (#1808)
Browse files Browse the repository at this point in the history
* Use a sprite for SVG icons

* Group licenses icons into a separate sprite

* Rename icon directories

* Add documentation

* Add svg-icon stub to unit test setup.js

* Fix Storybook

* Revert size changes to SVG icons

* Update code languages

* Apply Storybook fix by @sarayourfriend

* Rename icons to svg

* Rename icons to svg

* Fix import path

* Fix icon names
  • Loading branch information
obulat committed Apr 25, 2023
1 parent 267615d commit 7a071e2
Show file tree
Hide file tree
Showing 89 changed files with 258 additions and 320 deletions.
66 changes: 66 additions & 0 deletions documentation/guides/frontend/icons.md
@@ -0,0 +1,66 @@
# SVG Icon Sprites in Openverse Frontend

We use SVG for icons in Openverse frontend because it allows for high-quality
graphics, easy scalability without losing quality, and small file sizes. To
reduce the number of HTTP requests for icons and reduce load times, we combine
all of them into a single file called SVG sprite. An SVG sprite is a single SVG
file that contains multiple SVG symbols. Each symbol can be accessed using its
unique ID.

This guide explains how to use SVG sprites in Openverse Frontend and how to add
a new SVG file to the sprite.

## Using SVG Sprites in Openverse Frontend

You can use SVG sprites through the `VIcon` component. To use it, first, import
it into your component:

```js
import { VIcon } from "~/components/VIcon"
```

Then, add the VIcon component to your template, passing the name of the icon as
a prop:

```html
<template>
<VIcon name="arrow-up" />
</template>
```

The name prop is used to identify the icon within the SVG sprite. You can find
the names of the available icons in the `frontend/src/assets/icons/sprites.json`
file. The default icons listed in the object with the name `icons` use the name
as is, without prefixes. To use the license icons, you would need to add
`license/` as a prefix to the name. For example, to use an `external-link` icon,
you would use the name `external-link`, and for the `cc-by` icon, you would use
the name `license/cc-by`.

You can also view a list of all available icons if you go to
`http://localhost:8443/_icons`. You can copy the icon name (with the prefix, if
necessary) by clicking on the icon. This page is only available in development
environment and is not available in production.

## Adding New Icons to the Sprite

Openverse uses
[svg-sprite-module](https://github.com/nuxt-community/svg-sprite-module) to
automatically generate the SVG sprite when you add an SVG file to
`frontned/src/assets/icons/raw/`. It runs when you run `just frontend/run dev`
or `just frontend/run build`. The module also watches the content of the
`frontend/src/assets/icons/raw` directory, and re-generates the sprite if you
add an icon there, so you don't need to re-run the app.

So, to add a new icon to the SVG sprite, you only need to create an SVG file
containing the icon in `frontend/src/assets/icons/raw/` directory. The name of
the file without the `.svg` extension will be used in the component. There are
several requirements for the file:

- It should contain the SVG code for the icon, with a `path` with `id` icon that
wraps the inner contents.
- The color of the icon should be `currentColor`. This allows the icon to
inherit the color of the parent element. Also, remove white backgrounds from
the icon.
- It should have a `viewBox` attribute with the dimensions of the icon.
Openverse uses 24x24 icons. If you are exporting the icon from Figma, resize
the containing frame to 24x24 before exporting.
10 changes: 9 additions & 1 deletion frontend/.storybook/main.js
@@ -1,6 +1,6 @@
const { nuxifyStorybook } = require("../.nuxt-storybook/storybook/main")

module.exports = nuxifyStorybook({
const storybook = nuxifyStorybook({
webpackFinal(config) {
// extend config here

Expand All @@ -13,3 +13,11 @@ module.exports = nuxifyStorybook({
// Add your addons here
],
})

const generatedIconsStory = storybook.stories.indexOf(
"@nuxtjs/svg-sprite/stories/*.stories.js"
)
storybook.stories[generatedIconsStory] =
"../node_modules/@nuxtjs/svg-sprite/stories/*.stories.js"

module.exports = storybook
5 changes: 5 additions & 0 deletions frontend/nuxt.config.ts
Expand Up @@ -155,6 +155,7 @@ const config: NuxtConfig = {
"@nuxtjs/composition-api/module",
"@nuxtjs/style-resources",
"@nuxtjs/svg",
"@nuxtjs/svg-sprite",
"@nuxtjs/eslint-module",
"@pinia/nuxt",
],
Expand All @@ -174,6 +175,10 @@ const config: NuxtConfig = {
{ path: "/healthcheck", handler: "~/server-middleware/healthcheck.js" },
{ path: "/robots.txt", handler: "~/server-middleware/robots.js" },
],
svgSprite: {
input: "~/assets/svg/raw",
output: "~/assets/svg/sprite",
},
i18n: {
baseUrl: "https://openverse.org",
locales: openverseLocales,
Expand Down
1 change: 1 addition & 0 deletions frontend/package.json
Expand Up @@ -68,6 +68,7 @@
"@nuxtjs/sentry": "^7.1.4",
"@nuxtjs/sitemap": "^2.4.0",
"@nuxtjs/svg": "^0.4.0",
"@nuxtjs/svg-sprite": "0.5.2",
"@pinia/nuxt": "0.2.1",
"@popperjs/core": "^2.11.2",
"@tailwindcss/typography": "^0.5.9",
Expand Down
9 changes: 0 additions & 9 deletions frontend/src/assets/icons/audio-wave.svg

This file was deleted.

6 changes: 0 additions & 6 deletions frontend/src/assets/icons/image.svg

This file was deleted.

1 change: 0 additions & 1 deletion frontend/src/assets/icons/source.svg

This file was deleted.

File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
5 changes: 5 additions & 0 deletions frontend/src/assets/svg/raw/licenses/share.svg
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
5 changes: 5 additions & 0 deletions frontend/src/assets/svg/raw/radiomark.svg
File renamed without changes
File renamed without changes
1 change: 1 addition & 0 deletions frontend/src/assets/svg/raw/source.svg
File renamed without changes
12 changes: 4 additions & 8 deletions frontend/src/components/VAudioTrack/VPlayPause.vue
Expand Up @@ -3,7 +3,7 @@
v-bind="$attrs"
:tabindex="isTabbable ? 0 : -1"
class="play-pause flex-shrink-0 border-dark-charcoal bg-dark-charcoal text-white focus-visible:border-pink focus-visible:shadow-ring focus-visible:outline-none active:shadow-ring disabled:opacity-70"
:icon-props="icon === undefined ? undefined : { iconPath: icon }"
:icon-props="icon === undefined ? undefined : { name: icon }"
:label="$t(label)"
:button-props="buttonProps"
@click.stop.prevent="handleClick"
Expand Down Expand Up @@ -38,14 +38,10 @@ import type { ButtonConnections, ButtonVariant } from "~/types/button"
import VIconButton from "~/components/VIconButton/VIconButton.vue"
import playIcon from "~/assets/icons/play.svg"
import pauseIcon from "~/assets/icons/pause.svg"
import replayIcon from "~/assets/icons/replay.svg"
const statusIconMap = {
playing: pauseIcon,
paused: playIcon,
played: replayIcon,
playing: "pause",
paused: "play",
played: "replay",
loading: undefined,
} as const
Expand Down
7 changes: 1 addition & 6 deletions frontend/src/components/VBackToSearchResultsLink.vue
Expand Up @@ -4,7 +4,7 @@
class="time inline-flex flex-row items-center gap-2 rounded-sm p-2 pe-3 text-xs font-semibold text-dark-charcoal-70 hover:text-dark-charcoal"
v-bind="$attrs"
>
<VIcon :icon-path="chevronIcon" :rtl-flip="true" />
<VIcon name="chevron-left" :rtl-flip="true" />
{{ $t("single-result.back") }}
</VLink>
</template>
Expand All @@ -15,8 +15,6 @@ import { defineComponent } from "vue"
import VIcon from "~/components/VIcon/VIcon.vue"
import VLink from "~/components/VLink.vue"
import chevronIcon from "~/assets/icons/chevron-left.svg"
/**
* This link takes the user from a single result back to the list of all
* results. It only appears if the user navigated from the search results.
Expand All @@ -27,8 +25,5 @@ export default defineComponent({
VLink,
},
inheritAttrs: false,
setup() {
return { chevronIcon }
},
})
</script>
5 changes: 1 addition & 4 deletions frontend/src/components/VCheckbox/VCheckbox.vue
Expand Up @@ -48,7 +48,7 @@
v-else
v-show="localCheckedState"
class="absolute inset-0 transform text-white"
:icon-path="checkIcon"
name="check"
:size="5"
/>
</span>
Expand All @@ -64,8 +64,6 @@ import { defineEvent } from "~/types/emits"
import VIcon from "~/components/VIcon/VIcon.vue"
import checkIcon from "~/assets/icons/check.svg"
type CheckboxAttrs = {
name: string
value: string
Expand Down Expand Up @@ -182,7 +180,6 @@ export default defineComponent({
})
}
return {
checkIcon,
localCheckedState,
labelClasses,
inputAttrs,
Expand Down
47 changes: 26 additions & 21 deletions frontend/src/components/VCloseButton.vue
@@ -1,14 +1,7 @@
<template>
<VIconButton
:button-props="{
variant:
variant === 'black'
? 'plain--avoid'
: variant === 'filled-white-light'
? 'filled-white'
: variant,
}"
:icon-props="{ iconPath }"
:button-props="buttonProps"
:icon-props="iconProps"
:borderless="true"
:size="size"
:class="{
Expand All @@ -24,12 +17,19 @@
<script lang="ts">
import { computed, defineComponent, PropType } from "vue"
import type { ButtonVariant } from "~/types/button"
import VIconButton from "~/components/VIconButton/VIconButton.vue"
import type { TranslateResult } from "vue-i18n"
import closeIcon from "~/assets/icons/close.svg"
import closeIconSmall from "~/assets/icons/close-small.svg"
type CloseButtonVariant =
| "filled-white"
| "filled-white-light"
| "filled-transparent"
| "filled-dark"
| "black"
| "plain--avoid"
/**
* The square icon button with a cross in it. Used to close popovers,
Expand All @@ -54,13 +54,7 @@ export default defineComponent({
* @default "filled-white-light"
*/
variant: {
type: String as PropType<
| "filled-white"
| "filled-white-light"
| "filled-transparent"
| "filled-dark"
| "black"
>,
type: String as PropType<CloseButtonVariant>,
default: "filled-white-light",
},
/**
Expand All @@ -81,12 +75,23 @@ export default defineComponent({
},
},
setup(props) {
const iconPath = computed<string>(() => {
return props.iconSize === "small" ? closeIconSmall : closeIcon
const iconProps = computed(() => {
return { name: props.iconSize === "small" ? "close-small" : "close" }
})
const buttonProps = computed<{ variant: ButtonVariant }>(() => {
const variant =
props.variant === "black"
? "plain--avoid"
: props.variant === "filled-white-light"
? "filled-white"
: props.variant
return { variant }
})
return {
iconPath,
buttonProps,
iconProps,
}
},
})
Expand Down
18 changes: 3 additions & 15 deletions frontend/src/components/VContentLink/VContentLink.vue
Expand Up @@ -11,7 +11,7 @@
@keydown.native.shift.tab.exact="$emit('shift-tab', $event)"
>
<div class="flex flex-col items-start md:flex-row md:items-center">
<VIcon :icon-path="iconPath" />
<VIcon :name="mediaType" />
<p class="hidden pt-1 font-semibold md:block md:ps-2 md:pt-0 md:text-2xl">
{{ $t(`search-type.see-${mediaType}`) }}
</p>
Expand All @@ -27,24 +27,15 @@
import { computed, defineComponent, PropType } from "vue"
import { useI18nResultsCount } from "~/composables/use-i18n-utilities"
import { AUDIO, IMAGE, SupportedMediaType } from "~/constants/media"
import type { SupportedMediaType } from "~/constants/media"
import { defineEvent } from "~/types/emits"
import VIcon from "~/components/VIcon/VIcon.vue"
import VLink from "~/components/VLink.vue"
import audioIcon from "~/assets/icons/audio-wave.svg"
import imageIcon from "~/assets/icons/image.svg"
const iconMapping = {
[AUDIO]: audioIcon,
[IMAGE]: imageIcon,
}
export default defineComponent({
name: "VContentLink",
components: { VIcon, VLink },
components: { VLink },
props: {
/**
* One of the media types supported.
Expand Down Expand Up @@ -72,14 +63,11 @@ export default defineComponent({
"shift-tab": defineEvent<[KeyboardEvent]>(),
},
setup(props) {
const iconPath = computed(() => iconMapping[props.mediaType])
const { getI18nCount } = useI18nResultsCount()
const hasResults = computed(() => props.resultsCount > 0)
const resultsCountLabel = computed(() => getI18nCount(props.resultsCount))
return {
iconPath,
imageIcon,
resultsCountLabel,
hasResults,
}
Expand Down
11 changes: 1 addition & 10 deletions frontend/src/components/VContentReport/VContentReportButton.vue
Expand Up @@ -9,7 +9,7 @@
<span class="hidden text-sr md:inline md:text-base">{{
$t("media-details.content-report.long")
}}</span>
<VIcon :icon-path="icons.flag" class="ms-2" />
<VIcon name="flag" class="ms-2" />
</VButton>
</template>

Expand All @@ -19,18 +19,9 @@ import { defineComponent } from "vue"
import VIcon from "~/components/VIcon/VIcon.vue"
import VButton from "~/components/VButton.vue"
import flagIcon from "~/assets/icons/flag.svg"
export default defineComponent({
name: "VContentReportButton",
components: { VButton, VIcon },
setup() {
return {
icons: {
flag: flagIcon,
},
}
},
})
</script>

Expand Down

0 comments on commit 7a071e2

Please sign in to comment.