├── .gitignore ├── docs ├── favicon.ico ├── favicon-16x16.png ├── favicon-32x32.png ├── index.html ├── App.tsx ├── app.css └── index.js ├── .npmignore ├── tsconfig.test.json ├── .mergify.yml ├── tsconfig.json ├── .babelrc ├── setupTests.js ├── .github ├── dependabot.yml └── workflows │ └── main.yml ├── src ├── __tests__ │ ├── stringify.test.ts │ ├── Client.test.ts │ ├── mockResults.ts │ ├── Result.test.tsx │ ├── testServer.ts │ └── Tenor.test.tsx ├── TenorAPI.ts ├── Result.tsx ├── Client.ts ├── styles.css ├── Search.tsx └── Tenor.tsx ├── webpack.config.babel.ts ├── LICENSE ├── package.json ├── CODE_OF_CONDUCT.md ├── README.md └── CHANGELOG.md /.gitignore: -------------------------------------------------------------------------------- 1 | /.eslintcache 2 | /coverage/ 3 | /dist/ 4 | /node_modules/ 5 | -------------------------------------------------------------------------------- /docs/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CultureHQ/react-tenor/HEAD/docs/favicon.ico -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /.* 2 | /coverage 3 | /webpack* 4 | /docs/ 5 | /example/ 6 | /src/ 7 | /test/ 8 | /yarn* 9 | -------------------------------------------------------------------------------- /docs/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CultureHQ/react-tenor/HEAD/docs/favicon-16x16.png -------------------------------------------------------------------------------- /docs/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CultureHQ/react-tenor/HEAD/docs/favicon-32x32.png -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "declaration": false, 5 | "noEmit": true 6 | }, 7 | "exclude": [] 8 | } 9 | -------------------------------------------------------------------------------- /.mergify.yml: -------------------------------------------------------------------------------- 1 | pull_request_rules: 2 | - name: Automatically merge dependencies 3 | conditions: 4 | - base=master 5 | - label=dependencies 6 | - status-success=CI 7 | actions: 8 | merge: 9 | strict: true 10 | delete_head_branch: {} 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "jsx": "react", 5 | "module": "commonjs", 6 | "moduleResolution": "node", 7 | "outDir": "dist", 8 | "sourceMap": false, 9 | "strict": true, 10 | "target": "es5" 11 | }, 12 | "include": ["src"], 13 | "exclude": ["**/__tests__/*"] 14 | } 15 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["@babel/proposal-class-properties"], 3 | "presets": [ 4 | "@babel/preset-env", 5 | "@babel/preset-react", 6 | "@babel/preset-typescript" 7 | ], 8 | "env": { 9 | "test": { 10 | "presets": [ 11 | ["@babel/preset-env", { "targets": { "node": "current" } }], 12 | ] 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /setupTests.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | 3 | import { configure } from "enzyme"; 4 | import Adapter from "enzyme-adapter-react-16"; 5 | 6 | import { startTestServer, stopTestServer } from "./src/__tests__/testServer"; 7 | 8 | configure({ adapter: new Adapter() }); 9 | 10 | beforeAll(() => startTestServer()); 11 | afterAll(() => stopTestServer()); 12 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "10:00" 8 | open-pull-requests-limit: 10 9 | ignore: 10 | - dependency-name: typescript 11 | versions: 12 | - 4.1.3 13 | - 4.1.4 14 | - 4.1.5 15 | - 4.2.2 16 | - 4.2.3 17 | - dependency-name: css-loader 18 | versions: 19 | - 5.2.0 20 | -------------------------------------------------------------------------------- /src/__tests__/stringify.test.ts: -------------------------------------------------------------------------------- 1 | import { stringify } from "../Client"; 2 | 3 | test("works for empty objects", () => { 4 | const stringified = stringify({}); 5 | 6 | expect(stringified).toEqual("?"); 7 | }); 8 | 9 | test("works for single values", () => { 10 | const stringified = stringify({ foo: "bar" }); 11 | 12 | expect(stringified).toEqual("?foo=bar"); 13 | }); 14 | 15 | test("works for multiple values", () => { 16 | const stringified = stringify({ foo: "bar", bar: "baz" }); 17 | 18 | expect(stringified).toEqual("?foo=bar&bar=baz"); 19 | }); 20 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | react-tenor 12 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/__tests__/Client.test.ts: -------------------------------------------------------------------------------- 1 | import Client from "../Client"; 2 | import mockResults from "./mockResults"; 3 | import testServer from "./testServer"; 4 | 5 | test("sets sane defaults", () => { 6 | const client = new Client({}); 7 | 8 | expect(client.base).toContain("api.tenor.com"); 9 | expect(typeof client.token).toEqual("string"); 10 | }); 11 | 12 | test("fetches the expected results", async () => { 13 | const client = new Client({ base: `http://localhost:${testServer.port}`, token: "token" }); 14 | const response = await client.search("Happy"); 15 | 16 | expect(response.results).toEqual(mockResults.search); 17 | }); 18 | -------------------------------------------------------------------------------- /webpack.config.babel.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | 3 | export default { 4 | output: { 5 | path: path.join(__dirname, "docs"), 6 | filename: "index.js" 7 | }, 8 | entry: path.join(__dirname, "docs", "App.tsx"), 9 | resolve: { 10 | extensions: [".js", ".ts", ".tsx"] 11 | }, 12 | module: { 13 | rules: [ 14 | { test: /\.tsx?$/, use: "awesome-typescript-loader" }, 15 | { 16 | test: /\.css$/, 17 | use: [{ loader: "style-loader" }, { loader: "css-loader" }], 18 | exclude: /node_modules/ 19 | } 20 | ] 21 | }, 22 | devServer: { 23 | contentBase: path.join(__dirname, "docs") 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /src/TenorAPI.ts: -------------------------------------------------------------------------------- 1 | type MediaType = { 2 | preview: string; 3 | url: string; 4 | dims: number[]; 5 | size: number; 6 | }; 7 | 8 | type Media = { 9 | tinygif: MediaType; 10 | gif: MediaType; 11 | mp4: MediaType; 12 | }; 13 | 14 | export type Result = { 15 | created: number; 16 | hasaudio: boolean; 17 | id: string; 18 | media: Media[]; 19 | tags: string[]; 20 | itemurl: string; 21 | hascaption: boolean; 22 | url: string; 23 | }; 24 | 25 | export type AutocompleteResponse = { 26 | results: string[]; 27 | }; 28 | 29 | export type SearchResponse = { 30 | next?: string; 31 | results: Result[]; 32 | }; 33 | 34 | export type SuggestionsResponse = { 35 | results: string[]; 36 | }; 37 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Main 2 | on: push 3 | jobs: 4 | ci: 5 | name: CI 6 | runs-on: ubuntu-latest 7 | env: 8 | CI: true 9 | steps: 10 | - uses: actions/checkout@master 11 | - uses: actions/setup-node@v2-beta 12 | with: 13 | node-version: 14.x 14 | - id: yarn-cache 15 | run: echo "::set-output name=directory::$(yarn cache dir)" 16 | - uses: actions/cache@v1 17 | with: 18 | path: ${{ steps.yarn-cache.outputs.directory }} 19 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 20 | restore-keys: | 21 | ${{ runner.os }}-yarn- 22 | - run: | 23 | yarn install --frozen-lockfile 24 | yarn lint 25 | yarn compile 26 | yarn test 27 | -------------------------------------------------------------------------------- /src/__tests__/mockResults.ts: -------------------------------------------------------------------------------- 1 | import * as TenorAPI from "../TenorAPI"; 2 | 3 | let counter = 0; 4 | 5 | const makeId = () => { 6 | counter += 1; 7 | return counter.toString(); 8 | }; 9 | 10 | const makeResult = (): TenorAPI.Result => { 11 | const media = { 12 | preview: "https://via.placeholder.com/10x10", 13 | url: "https://via.placeholder.com/10x10", 14 | dims: [10, 10], 15 | size: 100 16 | }; 17 | 18 | return { 19 | created: 12345, 20 | hasaudio: false, 21 | id: makeId(), 22 | media: [{ tinygif: media, gif: media, mp4: media }], 23 | tags: [], 24 | itemurl: "https://tenor.com/view/this-is-a-test-gif-12345", 25 | hascaption: false, 26 | url: "https://tenor.com/12345" 27 | }; 28 | }; 29 | 30 | const mockResults = { /* eslint-disable camelcase */ 31 | autocomplete: ["test", "testing", "test2", "testingtesting", "testy testerson"], 32 | search_suggestions: ["test", "unit test", "acceptance test", "testing", "how to test"], 33 | search: [makeResult(), makeResult(), makeResult(), makeResult(), makeResult()] 34 | }; 35 | 36 | export default mockResults; 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018-present CultureHQ 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/__tests__/Result.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { shallow } from "enzyme"; 3 | 4 | import Result from "../Result"; 5 | import mockResults from "./mockResults"; 6 | 7 | const result = mockResults.search[0]; 8 | type OnLoadCallback = (event: Event) => void; 9 | 10 | test("renders without crashing", () => { 11 | const onSelect = jest.fn(); 12 | const component = shallow(); 13 | 14 | expect(component.type()).toEqual("button"); 15 | 16 | component.simulate("click"); 17 | expect(onSelect).toHaveBeenCalledWith(result); 18 | }); 19 | 20 | test("loads the image in the background", () => { 21 | const component = shallow(); 22 | expect(component.find("span")).toHaveLength(0); 23 | 24 | const { image } = component.instance(); 25 | (image.onload as OnLoadCallback)(new Event("onload")); 26 | 27 | component.update(); 28 | expect(component.find("span")).toHaveLength(1); 29 | }); 30 | 31 | test("does not attempt to set state if the image finishes after unmount", () => { 32 | const component = shallow(); 33 | const { image } = component.instance(); 34 | 35 | component.unmount(); 36 | (image.onload as OnLoadCallback)(new Event("onload")); 37 | }); 38 | -------------------------------------------------------------------------------- /docs/App.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as ReactDOM from "react-dom"; 3 | 4 | import Tenor, { Result } from "../src/Tenor"; 5 | import "../src/styles.css"; 6 | 7 | const App = () => { 8 | const [selected, setSelected] = React.useState(null); 9 | 10 | return ( 11 | <> 12 | 13 |
14 |

15 | react-tenor 16 |

17 |
18 | {selected && Selected GIF} 19 |
20 | 21 |
22 | {ReactDOM.createPortal( 23 | , 36 | document.body 37 | )} 38 | 39 | ); 40 | }; 41 | 42 | ReactDOM.render(, document.getElementById("main")); 43 | -------------------------------------------------------------------------------- /src/__tests__/testServer.ts: -------------------------------------------------------------------------------- 1 | import { createServer } from "http"; 2 | 3 | import mockResults from "./mockResults"; 4 | 5 | type TestServer = ReturnType & { 6 | port: number; 7 | requests: Record; 8 | }; 9 | 10 | const testServer = createServer() as TestServer; 11 | 12 | testServer.port = 8080; 13 | testServer.requests = { 14 | autocomplete: 0, 15 | search_suggestions: 0, 16 | search: 0 17 | }; 18 | 19 | const getRequestKey = (url: string) => url.slice(1).substring(0, url.indexOf("?") - 1); 20 | 21 | export const startTestServer = (): Promise => new Promise(resolve => { 22 | testServer.on("request", (request, response) => { 23 | const requestKey = getRequestKey(request.url) as keyof typeof mockResults; 24 | testServer.requests[requestKey] += 1; 25 | 26 | response.writeHead(200, { 27 | "Content-Type": "application/json", 28 | "Access-Control-Allow-Origin": "*", 29 | "Access-Control-Allow-Methods": "OPTIONS, GET" 30 | }); 31 | 32 | response.write(JSON.stringify({ results: mockResults[requestKey], next: "12" })); 33 | response.end(); 34 | }); 35 | 36 | testServer.on("error", () => { 37 | testServer.close(() => { 38 | testServer.port += 1; 39 | testServer.listen(testServer.port); 40 | }); 41 | }); 42 | 43 | testServer.on("listening", resolve); 44 | 45 | testServer.listen({ port: testServer.port, host: "localhost", exclusive: true }); 46 | }); 47 | 48 | export const stopTestServer = (): Promise => ( 49 | new Promise(resolve => { 50 | testServer.close(() => resolve()); 51 | }) 52 | ); 53 | 54 | export default testServer; 55 | -------------------------------------------------------------------------------- /docs/app.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | height: 100%; 3 | margin: 0; 4 | } 5 | 6 | body { 7 | background-color: #f7f7f7; 8 | color: #5c5f67; 9 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, 10 | sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; 11 | font-size: 16px; 12 | line-height: 1.42857143; 13 | margin: 0; 14 | padding: 0; 15 | } 16 | 17 | #main { 18 | box-sizing: border-box; 19 | min-height: 100%; 20 | } 21 | 22 | main { 23 | box-sizing: border-box; 24 | margin: 0 auto; 25 | padding: 40px 0 170px 0; 26 | text-align: center; 27 | } 28 | 29 | nav { 30 | background-color: #2c3e4f; 31 | box-shadow: 0 2px 3px rgba(0, 0, 0, 0.2); 32 | color: #f7f7f7; 33 | min-height: 50px; 34 | padding: 5px 20px 0; 35 | font-size: 18px; 36 | line-height: 42px; 37 | box-sizing: border-box; 38 | } 39 | 40 | h1 a { 41 | color: #2c3e4f; 42 | text-decoration: none; 43 | } 44 | 45 | h1 a:hover { 46 | color: #6a89af; 47 | text-decoration: underline; 48 | } 49 | 50 | .selected { 51 | background: #6a89af; 52 | background-image: repeating-linear-gradient( 53 | 45deg, 54 | rgba(255, 255, 255, .1), 55 | rgba(255, 255, 255, .1) 15px, 56 | transparent 0, 57 | transparent 30px 58 | ); 59 | display: inline-block; 60 | margin-bottom: 20px; 61 | min-height: 100px; 62 | min-width: 100px; 63 | } 64 | 65 | .react-tenor { 66 | margin: 0 auto; 67 | text-align: left; 68 | } 69 | 70 | footer { 71 | background-color: #2c3e4f; 72 | box-shadow: inset 0 2px 2px rgba(0, 0, 0, .4); 73 | color: #f7f7f7; 74 | text-align: center; 75 | height: 130px; 76 | margin-top: -130px; 77 | } 78 | 79 | footer p { 80 | box-sizing: border-box; 81 | margin: 0; 82 | padding: 32px 0 10px; 83 | } 84 | 85 | footer a, footer a:hover { 86 | color: #f7f7f7; 87 | } 88 | -------------------------------------------------------------------------------- /src/Result.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import * as TenorAPI from "./TenorAPI"; 4 | 5 | const BASE = "https://tenor.com/view/"; 6 | 7 | type ResultProps = { 8 | onSelect: (result: TenorAPI.Result) => void; 9 | result: TenorAPI.Result; 10 | }; 11 | 12 | type ResultState = { 13 | loaded: boolean; 14 | }; 15 | 16 | class Result extends React.Component { 17 | private componentIsMounted: boolean; 18 | 19 | public image: HTMLImageElement; 20 | 21 | constructor(props: ResultProps) { 22 | super(props); 23 | 24 | this.componentIsMounted = false; 25 | this.image = new Image(); 26 | 27 | this.state = { loaded: false }; 28 | } 29 | 30 | componentDidMount(): void { 31 | this.componentIsMounted = true; 32 | 33 | const { result } = this.props; 34 | 35 | this.image.src = result.media[0].tinygif.url; 36 | this.image.onload = () => { 37 | if (this.componentIsMounted) { 38 | this.setState({ loaded: true }); 39 | } 40 | }; 41 | } 42 | 43 | componentWillUnmount(): void { 44 | this.componentIsMounted = false; 45 | } 46 | 47 | getLabel(): string { 48 | const { result: { itemurl } } = this.props; 49 | 50 | return itemurl.replace(BASE, "").replace(/-gif-\d+$/, "").replace(/-/g, " "); 51 | } 52 | 53 | handleClick = (): void => { 54 | const { result, onSelect } = this.props; 55 | 56 | onSelect(result); 57 | }; 58 | 59 | render(): React.ReactElement { 60 | const { loaded } = this.state; 61 | 62 | return ( 63 | 71 | ); 72 | } 73 | } 74 | 75 | export default Result; 76 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-tenor", 3 | "version": "2.2.0", 4 | "description": "Integrate with the Tenor GIF API", 5 | "main": "dist/Tenor.js", 6 | "types": "dist/Tenor.d.ts", 7 | "scripts": { 8 | "compile": "tsc --project tsconfig.test.json", 9 | "docs": "webpack --mode production", 10 | "lint": "chq-scripts lint", 11 | "prepublishOnly": "rm -rf dist && yarn tsc && cp src/styles.css dist/styles.css", 12 | "start": "webpack-dev-server --mode development --hot", 13 | "test": "chq-scripts test" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/CultureHQ/react-tenor.git" 18 | }, 19 | "author": "Kevin Deisz", 20 | "license": "MIT", 21 | "bugs": { 22 | "url": "https://github.com/CultureHQ/react-tenor/issues" 23 | }, 24 | "homepage": "https://github.com/CultureHQ/react-tenor#readme", 25 | "peerDependencies": { 26 | "react": "^16", 27 | "react-dom": "^16" 28 | }, 29 | "devDependencies": { 30 | "@babel/cli": "^7.12.1", 31 | "@babel/core": "^7.12.1", 32 | "@babel/plugin-proposal-class-properties": "^7.12.1", 33 | "@babel/preset-env": "^7.12.1", 34 | "@babel/preset-react": "^7.12.1", 35 | "@babel/preset-typescript": "^7.12.1", 36 | "@babel/register": "^7.12.1", 37 | "@culturehq/scripts": "^6.0.1", 38 | "@types/enzyme": "^3.10.7", 39 | "@types/jest": "^27.0.0", 40 | "@types/react": "^17.0.0", 41 | "@types/react-dom": "^17.0.3", 42 | "awesome-typescript-loader": "^5.2.1", 43 | "babel-loader": "^8.0.5", 44 | "css-loader": "^6.0.0", 45 | "enzyme": "^3.11.0", 46 | "enzyme-adapter-react-16": "^1.15.2", 47 | "react": "^16.9.0", 48 | "react-dom": "^16.14.0", 49 | "style-loader": "^3.0.0", 50 | "typescript": "^4.0.3", 51 | "webpack": "^5.1.3", 52 | "webpack-cli": "^4.0.0", 53 | "webpack-dev-server": "^4.0.0" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Client.ts: -------------------------------------------------------------------------------- 1 | import * as TenorAPI from "./TenorAPI"; 2 | 3 | type Query = { 4 | [key: string]: string | number | undefined; 5 | }; 6 | 7 | export const stringify = (query: Query): string => { 8 | const keyValuePairs: string[] = []; 9 | 10 | Object.keys(query).forEach(key => { 11 | if (query[key] !== undefined) { 12 | keyValuePairs.push(`${key}=${query[key]}`); 13 | } 14 | }); 15 | 16 | return encodeURI(`?${keyValuePairs.join("&")}`); 17 | }; 18 | 19 | const fetch = ( 20 | >(base: string, path: string, query: Query): Promise => ( 21 | new Promise((resolve, reject) => { 22 | const xhr = new XMLHttpRequest(); 23 | 24 | xhr.onreadystatechange = () => { 25 | if (xhr.readyState !== 4) { 26 | return; 27 | } 28 | 29 | if (xhr.status >= 200 && xhr.status < 300) { 30 | resolve(JSON.parse(xhr.responseText)); 31 | } else { 32 | reject(new Error(xhr.responseText)); 33 | } 34 | }; 35 | 36 | xhr.open("GET", `${base}${path}${stringify(query)}`); 37 | xhr.send(); 38 | }) 39 | ) 40 | ); 41 | 42 | type ClientOptions = { 43 | base?: string; 44 | token?: string; 45 | locale?: string; 46 | contentFilter?: string; 47 | mediaFilter?: string; 48 | defaultResults?: boolean; 49 | limit?: number; 50 | }; 51 | 52 | class Client { 53 | public base: string; 54 | 55 | public token: string; 56 | 57 | private locale: string; 58 | 59 | private contentFilter: string; 60 | 61 | private mediaFilter: string; 62 | 63 | private defaultResults: boolean; 64 | 65 | private limit: number; 66 | 67 | constructor(opts: ClientOptions) { 68 | this.base = opts.base || "https://api.tenor.com/v1"; 69 | this.token = opts.token || "LIVDSRZULELA"; 70 | this.locale = opts.locale || "en_US"; 71 | this.contentFilter = opts.contentFilter || "mild"; 72 | this.mediaFilter = opts.mediaFilter || "minimal"; 73 | this.defaultResults = opts.defaultResults || false; 74 | this.limit = opts.limit || 12; 75 | } 76 | 77 | autocomplete(search: string): Promise { 78 | return fetch(this.base, "/autocomplete", { 79 | key: this.token, 80 | q: search, 81 | limit: 1, 82 | locale: "en_US" 83 | }); 84 | } 85 | 86 | search(search: string, pos?: string): Promise { 87 | const searchQuery = (this.defaultResults && !search) ? "/trending" : "/search"; 88 | 89 | return fetch(this.base, searchQuery, { 90 | key: this.token, 91 | q: search, 92 | limit: this.limit, 93 | locale: this.locale, 94 | contentfilter: this.contentFilter, 95 | media_filter: this.mediaFilter, 96 | ar_range: "all", 97 | pos 98 | }); 99 | } 100 | 101 | suggestions(search: string): Promise { 102 | return fetch(this.base, "/search_suggestions", { 103 | key: this.token, 104 | q: search, 105 | limit: 5, 106 | locale: this.locale 107 | }); 108 | } 109 | } 110 | 111 | export default Client; 112 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | - Using welcoming and inclusive language 18 | - Being respectful of differing viewpoints and experiences 19 | - Gracefully accepting constructive criticism 20 | - Focusing on what is best for the community 21 | - Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | - The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | - Trolling, insulting/derogatory comments, and personal or political attacks 28 | - Public or private harassment 29 | - Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | - Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at support@culturehq.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-tenor 2 | 3 | [![Build Status](https://github.com/CultureHQ/react-tenor/workflows/Main/badge.svg)](https://github.com/CultureHQ/react-tenor/actions) 4 | [![Package Version](https://img.shields.io/npm/v/react-tenor.svg)](https://www.npmjs.com/package/react-tenor) 5 | 6 | A React component for selected GIFs from [Tenor](https://tenor.com/gifapi). 7 | 8 | ## Getting started 9 | 10 | First, add `react-tenor` to your `package.json` `dependencies`, then install using either `npm install` or `yarn install`. Then, get your API key from tenor. Finally, you can add the selector component by adding: 11 | 12 | ```jsx 13 | console.log(result)} /> 14 | ``` 15 | 16 | ### Styles 17 | 18 | To get the styles, be sure it import `react-tenor/dist/styles.css` into your application. You can style it appropriately for your app by overriding the CSS classes used internally. They are listed in [`styles.css`](src/styles.css). 19 | 20 | ### Props 21 | 22 | Below is a list of all of the props you can pass to the `Tenor` component. 23 | 24 | | Name | Type | Default | Description | 25 | | ------------------- | ---------------- | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | 26 | | `autoFocus` | `boolean` | `false` | Indicates that the search bar of the component should request focus when it first mounts. | 27 | | `base` | `string` | `"api.tenor.com/v1"` | The base of the API that this component hits. | 28 | | `contentFilter` | `string` | `"mild"` | The content filter that gets passed up to tenor. See the [tenor API docs](https://tenor.com/gifapi/documentation#contentfilter) for details. | 29 | | `contentRef` | `Ref` | `null` | A ref to the `div` that the `Tenor` component renders. | 30 | | `defaultResults` | `boolean` | `false` | Indicates that the component should automatically search for trending results if the search input is empty. | 31 | | `initialSearch` | `string` | `""` | The starting value of the search bar. | 32 | | `limit` | `number` | `12` | The number of results to return for each search. | 33 | | `locale` | `string` | `"en_US"` | The locale that gets passed up to tenor. See the [tenor API docs](https://tenor.com/gifapi/documentation) for details. | 34 | | `mediaFilter` | `string` | `"minimal"` | The media filter that gets passed up to tenor. See the [tenor API docs](https://tenor.com/gifapi/documentation) for details. | 35 | | `onSelect` | `Result => void` | | A callback for when the user selects a GIF. | 36 | | `searchPlaceholder` | `string` | `"Search Tenor"` | The placeholder that is applied to the search input field. | 37 | | `token` | `string` | | The tenor API token. See the [tenor API docs](https://tenor.com/gifapi/documentation) for details. | 38 | 39 | ## Testing locally 40 | 41 | You can run the tests by running `yarn test` and lint by running `yarn lint`. You can run the local server by running `yarn start` which will start the docs server on `http://localhost:8080`. 42 | 43 | ## Contributing 44 | 45 | Bug reports and pull requests are welcome on GitHub at https://github.com/CultureHQ/react-tenor. 46 | 47 | ## License 48 | 49 | The code is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 50 | -------------------------------------------------------------------------------- /src/styles.css: -------------------------------------------------------------------------------- 1 | .react-tenor { 2 | background-color: #f7f7f7; 3 | border: 1px solid #ccc; 4 | max-width: 480px; 5 | } 6 | 7 | .react-tenor-active { 8 | box-shadow: 0 0 5px 1px rgba(0, 0, 0, .2); 9 | } 10 | 11 | .react-tenor--search-bar { 12 | position: relative; 13 | } 14 | 15 | .react-tenor--search { 16 | background-color: white; 17 | border: 1px solid #f7f7f7; 18 | box-sizing: border-box; 19 | color: #555; 20 | font-family: Arial; 21 | font-size: 1em; 22 | line-height: 1.3; 23 | overflow: visible; 24 | padding: .25em .5em; 25 | width: 100%; 26 | } 27 | 28 | .react-tenor--search:focus { 29 | box-shadow: 0 0 2px 2px #6a89af; 30 | outline: none; 31 | } 32 | 33 | .react-tenor--autocomplete { 34 | box-sizing: border-box; 35 | color: #aaa; 36 | font-family: Arial; 37 | font-size: 1em; 38 | line-height: 1.3; 39 | left: 0; 40 | padding: .25em .5em; 41 | pointer-events: none; 42 | position: absolute; 43 | top: 1px; 44 | } 45 | 46 | .react-tenor--autocomplete span { 47 | visibility: hidden; 48 | } 49 | 50 | .react-tenor--spinner { 51 | animation: react-tenor-spin 1s linear infinite; 52 | height: 22px; 53 | position: absolute; 54 | right: 4px; 55 | top: 3px; 56 | width: 22px; 57 | } 58 | 59 | .react-tenor--spinner path { 60 | fill: #999; 61 | } 62 | 63 | .react-tenor--suggestions { 64 | overflow-x: auto; 65 | padding: .5em .5em; 66 | white-space: nowrap; 67 | } 68 | 69 | .react-tenor--suggestions button { 70 | background: #6a89af; 71 | border: 1px solid #f7f7f7; 72 | border-radius: 5px; 73 | color: white; 74 | cursor: pointer; 75 | display: inline-block; 76 | font-size: 1em; 77 | padding: 3px 5px; 78 | } 79 | 80 | .react-tenor--suggestions button:focus { 81 | box-shadow: 0 0 2px 2px #6a89af; 82 | outline: none; 83 | } 84 | 85 | .react-tenor--suggestions button + button { 86 | margin-left: .5em; 87 | } 88 | 89 | .react-tenor--results { 90 | display: flex; 91 | flex-wrap: wrap; 92 | position: relative; 93 | } 94 | 95 | .react-tenor--result { 96 | background: #6a89af; 97 | background-image: repeating-linear-gradient( 98 | 45deg, 99 | rgba(255, 255, 255, .1), 100 | rgba(255, 255, 255, .1) 15px, 101 | transparent 0, 102 | transparent 30px 103 | ); 104 | border: 0; 105 | cursor: pointer; 106 | display: inline-block; 107 | flex-basis: 25%; 108 | height: 120px; 109 | opacity: 1; 110 | padding: 0; 111 | transition: opacity .3s; 112 | width: 120px; 113 | } 114 | 115 | .react-tenor--result span { 116 | animation: react-tenor-fade-in .2s; 117 | background-size: cover; 118 | display: block; 119 | height: 100%; 120 | width: 100%; 121 | } 122 | 123 | .react-tenor--result:focus { 124 | box-shadow: 0 0 2px 2px #6a89af; 125 | border: 1px solid #f7f7f7; 126 | outline: none; 127 | z-index: 1; 128 | } 129 | 130 | .react-tenor--result:hover { 131 | opacity: .5; 132 | } 133 | 134 | @media screen and (max-width: 480px) { 135 | .react-tenor--result { 136 | flex-basis: 33%; 137 | } 138 | } 139 | 140 | .react-tenor--page-left, 141 | .react-tenor--page-right { 142 | background: #6a89af; 143 | border: 0; 144 | cursor: pointer; 145 | height: 1.8em; 146 | position: absolute; 147 | top: calc(50% - .9em); 148 | opacity: .5; 149 | position: absolute; 150 | transition: opacity .2s, left .2s, right .2s; 151 | width: 1.8em; 152 | z-index: -1; 153 | } 154 | 155 | .react-tenor--results:hover .react-tenor--page-left, 156 | .react-tenor--results:hover .react-tenor--page-right { 157 | opacity: 1; 158 | z-index: 1; 159 | } 160 | 161 | .react-tenor--results:hover .react-tenor--page-left { 162 | left: -1em; 163 | } 164 | 165 | .react-tenor--results:hover .react-tenor--page-right { 166 | right: -1em; 167 | } 168 | 169 | .react-tenor--page-left div, 170 | .react-tenor--page-right div { 171 | background: inherit; 172 | height: 1.6em; 173 | transform: rotate(45deg); 174 | top: .1em; 175 | position: absolute; 176 | width: 1.6em; 177 | } 178 | 179 | .react-tenor--page-left { 180 | left: -.3em; 181 | } 182 | 183 | .react-tenor--page-left div { 184 | left: -.7em; 185 | } 186 | 187 | .react-tenor--page-right { 188 | right: -.3em; 189 | } 190 | 191 | .react-tenor--page-right div { 192 | right: -.7em; 193 | } 194 | 195 | @keyframes react-tenor-fade-in { 196 | from { opacity: 0; } 197 | to { opacity: 1; } 198 | } 199 | 200 | @keyframes react-tenor-spin { 201 | from { transform: rotate(0deg); } 202 | to { transform: rotate(360deg); } 203 | } 204 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). 6 | 7 | ## [Unreleased] 8 | 9 | ## [2.2.0] - 2020-03-13 10 | 11 | ### Added 12 | 13 | - The `limit` prop to support setting how many results to return. 14 | 15 | ## [2.1.1] - 2019-09-23 16 | 17 | ### Changed 18 | 19 | - Correctly pass down the `searchPlaceholder` prop. 20 | 21 | ## [2.1.0] - 2019-09-19 22 | 23 | ### Added 24 | 25 | - The `searchPlaceholder` prop for configuring the placeholder on the search input. 26 | 27 | ## [2.0.0] - 2019-09-13 28 | 29 | ### Changed 30 | 31 | - Renamed the `safesearch` param on the component to `contentFilter`. 32 | 33 | ## [1.5.0] - 2019-09-12 34 | 35 | ### Added 36 | 37 | - The `locale`, `mediaFilter`, and `safesearch` props. 38 | 39 | ### Changed 40 | 41 | - Switch to TypeScript for development. 42 | - Do not fire off autocomplete and suggestion requests when the search is blank but you have `defaultResults` selected. 43 | 44 | ## [1.4.0] - 2019-08-09 45 | 46 | ### Added 47 | 48 | - The `autoFocus` prop to focus on the input when the component mounts. 49 | - The `defaultResults` prop to display trending results in empty search. 50 | 51 | ### Changed 52 | 53 | - No longer rely on `fetch` being available. 54 | - [INTERNAL] Refactor Client.js to be simpler. 55 | - Ensure `pointer` cursor on pagination controls. 56 | - Align typeahead text with current text. 57 | 58 | ## [1.3.2] - 2019-06-07 59 | 60 | ### Changed 61 | 62 | - Use the `prepublishOnly` npm script so that the `dist` directory does not need to be checked into the repository. 63 | 64 | ## [1.3.1] - 2019-06-07 65 | 66 | ### Changed 67 | 68 | - Properly rebuild dist. 69 | 70 | ## [1.3.0] - 2019-06-06 71 | 72 | ### Added 73 | 74 | - The `initialSearch` prop. 75 | 76 | ## [1.2.1] - 2019-06-05 77 | 78 | ### Changed 79 | 80 | - Rebuilt dist with correct capitalization of file names. 81 | 82 | ## [1.2.0] - 2019-05-22 83 | 84 | ### Added 85 | 86 | - Switched to using `@culturehq/scripts` for development. 87 | - Added `:focus` styles for relevant components for better keyboard support. 88 | 89 | ## [1.1.0] - 2018-10-03 90 | 91 | ### Added 92 | 93 | - Type ahead through the use of Tenor's autocomplete feature. 94 | - Suggestions are now rendered through the user of Tenor's suggestions feature. 95 | - Pagination is now supported. Additionally you can use the meta-key plus left arrow and right arrow to go between pages. 96 | 97 | ### Changed 98 | 99 | - Now encoding the URI being sent to tenor to ensure it's a valid URL. 100 | - If you click outside the component, the component now knows about that click and closes the selector. 101 | - The search bar is now of type "search" which builds in some nicities from the browsers that support it. 102 | - Added better accessibility support by properly naming the GIF buttons. 103 | - Load preview GIFs in the background and then set them to fade in once they are loaded. 104 | 105 | ## [1.0.0] - 2018-09-26 106 | 107 | ### Added 108 | 109 | - The optional `contentRef` prop to get access to the actual div that is being rendered. 110 | - The optional `base` prop that will specify the base of the API for the search URLs that are generated. 111 | - The `focus()` function on the main component to allow consumers to focus into the input field. 112 | 113 | ### Changed 114 | 115 | - Removed the style import by default. It's now up to the consumers of this package to import `react-tenor/dist/styles.css` into their applications. This avoids a lot of weird webpack bugs. 116 | 117 | ## [0.2.0] - 2018-07-18 118 | 119 | ### Changed 120 | 121 | - Don't build the final distribution with `webpack`, just use `babel`. 122 | - Rename `example` to `docs` so we can publish to github pages. 123 | 124 | [unreleased]: https://github.com/CultureHQ/react-tenor/compare/v2.2.0...HEAD 125 | [2.2.0]: https://github.com/CultureHQ/react-tenor/compare/v2.1.1...v2.2.0 126 | [2.1.1]: https://github.com/CultureHQ/react-tenor/compare/v2.1.0...v2.1.1 127 | [2.1.0]: https://github.com/CultureHQ/react-tenor/compare/v2.0.0...v2.1.0 128 | [2.0.0]: https://github.com/CultureHQ/react-tenor/compare/v1.5.0...v2.0.0 129 | [1.5.0]: https://github.com/CultureHQ/react-tenor/compare/v1.4.0...v1.5.0 130 | [1.4.0]: https://github.com/CultureHQ/react-tenor/compare/v1.3.2...v1.4.0 131 | [1.3.2]: https://github.com/CultureHQ/react-tenor/compare/v1.3.1...v1.3.2 132 | [1.3.1]: https://github.com/CultureHQ/react-tenor/compare/v1.3.0...v1.3.1 133 | [1.3.0]: https://github.com/CultureHQ/react-tenor/compare/v1.2.1...v1.3.0 134 | [1.2.1]: https://github.com/CultureHQ/react-tenor/compare/v1.2.0...v1.2.1 135 | [1.2.0]: https://github.com/CultureHQ/react-tenor/compare/v1.1.0...v1.2.0 136 | [1.1.0]: https://github.com/CultureHQ/react-tenor/compare/v1.0.0...v1.1.0 137 | [1.0.0]: https://github.com/CultureHQ/react-tenor/compare/v0.2.0...v1.0.0 138 | [0.2.0]: https://github.com/CultureHQ/react-tenor/compare/2d68b4...v0.2.0 139 | -------------------------------------------------------------------------------- /src/Search.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import * as TenorAPI from "./TenorAPI"; 4 | import Result from "./Result"; 5 | 6 | type AutoCompleteProps = { 7 | autoComplete: string; 8 | search: string; 9 | }; 10 | 11 | const AutoComplete: React.FC = ({ autoComplete, search }) => { 12 | const prefix = search.toLowerCase().replace(/\s/g, ""); 13 | const typeahead = autoComplete.toLowerCase().replace(prefix, ""); 14 | 15 | return ( 16 |
17 | {search} 18 | {typeahead} 19 |
20 | ); 21 | }; 22 | 23 | const Spinner: React.FC = () => ( 24 | 25 | 26 | 27 | ); 28 | 29 | type SearchBarProps = { 30 | autoComplete: string | null; 31 | inputRef: React.RefObject; 32 | onSearchChange: (event: React.ChangeEvent) => void; 33 | onSearchKeyDown: (event: React.KeyboardEvent) => void; 34 | search: string; 35 | searching: boolean; 36 | placeholder?: string; 37 | }; 38 | 39 | const SearchBar: React.FC = ({ 40 | autoComplete, inputRef, search, searching, onSearchChange, onSearchKeyDown, placeholder 41 | }) => ( 42 |
43 | 53 | {autoComplete && search && ( 54 | 55 | )} 56 | {searching && } 57 |
58 | ); 59 | 60 | type SuggestionProps = { 61 | onSuggestionClick: (suggestion: string) => void; 62 | suggestion: string; 63 | }; 64 | 65 | const Suggestion: React.FC = ({ suggestion, onSuggestionClick }) => { 66 | const onClick = () => onSuggestionClick(suggestion); 67 | 68 | return ; 69 | }; 70 | 71 | type SuggestionsProps = { 72 | onSuggestionClick: (suggestion: string) => void; 73 | suggestions: string[]; 74 | }; 75 | 76 | const Suggestions: React.FC = ({ suggestions, onSuggestionClick }) => ( 77 |
78 | {suggestions.map(suggestion => ( 79 | 84 | ))} 85 |
86 | ); 87 | 88 | type PageControlProps = { 89 | direction: "left" | "right"; 90 | onClick: (event: React.MouseEvent) => void; 91 | }; 92 | 93 | const PageControl: React.FC = ({ direction, onClick }) => ( 94 | 102 | ); 103 | 104 | type ResultProps = { 105 | onPageLeft: (event: React.MouseEvent) => void; 106 | onPageRight: (event: React.MouseEvent) => void; 107 | onSelect: (result: TenorAPI.Result) => void; 108 | results: TenorAPI.Result[]; 109 | }; 110 | 111 | const Results: React.FC = ({ results, onPageLeft, onPageRight, onSelect }) => ( 112 |
113 | {results.map(result => ( 114 | 115 | ))} 116 | 117 | 118 |
119 | ); 120 | 121 | type SearchProps = SearchBarProps & ResultProps & SuggestionsProps & { 122 | contentRef: React.RefObject; 123 | }; 124 | 125 | const Search: React.FC = ({ 126 | autoComplete, contentRef, inputRef, onPageLeft, onPageRight, onSearchChange, 127 | onSearchKeyDown, onSuggestionClick, onSelect, results, search, searching, 128 | suggestions, placeholder 129 | }) => { 130 | let classList = "react-tenor"; 131 | if (suggestions.length > 0 || results.length > 0) { 132 | classList = `${classList} react-tenor-active`; 133 | } 134 | 135 | return ( 136 |
137 | 146 | {suggestions.length > 0 && ( 147 | 151 | )} 152 | {results.length > 0 && ( 153 | 159 | )} 160 |
161 | ); 162 | }; 163 | 164 | export default Search; 165 | -------------------------------------------------------------------------------- /src/Tenor.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import * as TenorAPI from "./TenorAPI"; 4 | import Client from "./Client"; 5 | import Search from "./Search"; 6 | 7 | export const defaultState = { 8 | autoComplete: null, 9 | autoFocus: false, 10 | page: 0, 11 | pages: [], 12 | search: "", 13 | searching: false, 14 | suggestions: [] 15 | }; 16 | 17 | const searchDelay = 250; 18 | 19 | const keyCodes = { 20 | Tab: 9, 21 | ArrowLeft: 37, 22 | ArrowRight: 39 23 | }; 24 | 25 | type TenorProps = { 26 | autoFocus?: boolean; 27 | base?: string; 28 | contentRef?: React.RefObject; 29 | defaultResults?: boolean; 30 | initialSearch?: string; 31 | onSelect: (result: TenorAPI.Result) => void; 32 | token: string; 33 | locale?: string; 34 | mediaFilter?: string; 35 | contentFilter?: string; 36 | searchPlaceholder?: string; 37 | limit?: number; 38 | }; 39 | 40 | type TenorState = { 41 | autoComplete: string | null; 42 | page: number; 43 | pages: TenorAPI.SearchResponse[]; 44 | search: string; 45 | searching: boolean; 46 | suggestions: string[]; 47 | }; 48 | 49 | type SetState = ( 50 | ((prev: TenorState) => Pick) | Pick 51 | ); 52 | 53 | class Tenor extends React.Component { 54 | public client: Client; 55 | 56 | public componentIsMounted: boolean; 57 | 58 | public contentRef: React.RefObject; 59 | 60 | public inputRef: React.RefObject; 61 | 62 | public timeout: ReturnType | null; 63 | 64 | constructor(props: TenorProps) { 65 | super(props); 66 | 67 | this.client = this.makeClient(); 68 | 69 | this.contentRef = React.createRef(); 70 | this.inputRef = React.createRef(); 71 | 72 | this.timeout = null; 73 | this.componentIsMounted = false; 74 | 75 | this.state = { 76 | ...defaultState, 77 | search: props.initialSearch || "", 78 | searching: !!(props.initialSearch || props.defaultResults) 79 | }; 80 | } 81 | 82 | componentDidMount(): void { 83 | const { autoFocus, initialSearch, defaultResults } = this.props; 84 | 85 | this.componentIsMounted = true; 86 | window.addEventListener("keydown", this.handleWindowKeyDown); 87 | window.addEventListener("click", this.handleWindowClick); 88 | 89 | if (initialSearch) { 90 | this.fetchAutoComplete(initialSearch); 91 | this.fetchSuggestions(initialSearch); 92 | } 93 | 94 | if (initialSearch || defaultResults) { 95 | this.performSearch(initialSearch || ""); 96 | } 97 | 98 | if (autoFocus) { 99 | this.focus(); 100 | } 101 | } 102 | 103 | componentDidUpdate(prevProps: TenorProps): void { 104 | const { base, token, locale, mediaFilter, contentFilter, defaultResults, limit } = this.props; 105 | 106 | if ( 107 | base !== prevProps.base 108 | || token !== prevProps.token 109 | || locale !== prevProps.locale 110 | || mediaFilter !== prevProps.mediaFilter 111 | || contentFilter !== prevProps.contentFilter 112 | || defaultResults !== prevProps.defaultResults 113 | || limit !== prevProps.limit 114 | ) { 115 | this.client = this.makeClient(); 116 | } 117 | } 118 | 119 | componentWillUnmount(): void { 120 | window.removeEventListener("click", this.handleWindowClick); 121 | window.removeEventListener("keydown", this.handleWindowKeyDown); 122 | this.componentIsMounted = false; 123 | } 124 | 125 | fetchAutoComplete = (currentSearch: string): Promise => ( 126 | this.client.autocomplete(currentSearch).then(({ results: [autoComplete] }) => { 127 | const { search } = this.state; 128 | 129 | if (search === currentSearch) { 130 | this.mountedSetState({ autoComplete }); 131 | } 132 | }) 133 | ); 134 | 135 | fetchSuggestions = (currentSearch: string): Promise => ( 136 | this.client.suggestions(currentSearch).then(({ results: suggestions }) => { 137 | const { search } = this.state; 138 | 139 | if (search === currentSearch) { 140 | this.mountedSetState({ suggestions }); 141 | } 142 | }) 143 | ); 144 | 145 | handleWindowClick = (event: MouseEvent): void => { 146 | const { contentRef } = this.props; 147 | const { search } = this.state; 148 | 149 | if (!search) { 150 | return; 151 | } 152 | 153 | const container = (contentRef || this.contentRef).current; 154 | if (container && (event.target instanceof Element) && container.contains(event.target)) { 155 | return; 156 | } 157 | 158 | if (this.timeout) { 159 | clearTimeout(this.timeout); 160 | } 161 | 162 | this.setState(defaultState); 163 | }; 164 | 165 | handleWindowKeyDown = (event: KeyboardEvent): void => { 166 | const { contentRef } = this.props; 167 | const container = (contentRef || this.contentRef).current; 168 | 169 | if ( 170 | (container && (event.target instanceof Element) && !container.contains(event.target)) 171 | || ([keyCodes.ArrowLeft, keyCodes.ArrowRight].indexOf(event.keyCode) === -1) 172 | || !event.metaKey 173 | ) { 174 | return; 175 | } 176 | 177 | event.preventDefault(); 178 | 179 | if (event.keyCode === keyCodes.ArrowLeft) { 180 | this.handlePageLeft(); 181 | } else { 182 | this.handlePageRight(); 183 | } 184 | }; 185 | 186 | handlePageLeft = (): void => { 187 | this.setState(({ page }) => ({ page: page === 0 ? 0 : page - 1 })); 188 | }; 189 | 190 | handlePageRight = (): Promise => { 191 | const { defaultResults } = this.props; 192 | const { 193 | page, 194 | pages, 195 | search, 196 | searching 197 | } = this.state; 198 | 199 | if ((!defaultResults && !search) || searching) { 200 | return Promise.resolve(); 201 | } 202 | 203 | if (page < pages.length - 1) { 204 | this.setState(({ page: prevPage }) => ({ page: prevPage + 1 })); 205 | return Promise.resolve(); 206 | } 207 | 208 | return this.client.search(search, pages[page].next) 209 | .then((nextPage: TenorAPI.SearchResponse) => { 210 | if (nextPage.results) { 211 | this.mountedSetState(({ page: prevPage, pages: prevPages }) => ({ 212 | page: prevPage + 1, 213 | pages: prevPages.concat([nextPage]), 214 | searching: false 215 | })); 216 | } 217 | }).catch(() => { 218 | this.mountedSetState({ searching: false }); 219 | }); 220 | }; 221 | 222 | handleSearchChange = (event: React.ChangeEvent): void => { 223 | const { defaultResults } = this.props; 224 | const search = event.target.value; 225 | 226 | if (this.timeout) { 227 | clearTimeout(this.timeout); 228 | } 229 | 230 | if (!search.length) { 231 | if (defaultResults) { 232 | this.setState({ ...defaultState, searching: true }); 233 | this.performSearch(search); 234 | } else { 235 | this.setState(defaultState); 236 | } 237 | return; 238 | } 239 | 240 | this.setState({ autoComplete: null, search, searching: true }); 241 | this.fetchAutoComplete(search); 242 | this.fetchSuggestions(search); 243 | this.timeout = setTimeout(() => this.performSearch(search), searchDelay); 244 | }; 245 | 246 | handleSearchKeyDown = (event: React.KeyboardEvent): void => { 247 | const { autoComplete, search: prevSearch } = this.state; 248 | 249 | if (event.keyCode !== keyCodes.Tab || !autoComplete || !prevSearch) { 250 | return; 251 | } 252 | 253 | const lowerAutoComplete = autoComplete.toLowerCase(); 254 | const lowerSearch = prevSearch.toLowerCase().replace(/\s/g, ""); 255 | 256 | if (lowerAutoComplete === lowerSearch) { 257 | return; 258 | } 259 | 260 | event.preventDefault(); 261 | 262 | const typeahead = lowerAutoComplete.replace(lowerSearch, ""); 263 | const search = `${prevSearch}${typeahead}`; 264 | 265 | this.setState({ autoComplete: null, search, searching: true }); 266 | this.fetchSuggestions(search); 267 | this.performSearch(search); 268 | }; 269 | 270 | handleSuggestionClick = (suggestion: string): void => { 271 | if (this.timeout) { 272 | clearTimeout(this.timeout); 273 | } 274 | 275 | this.setState({ search: suggestion, searching: true }); 276 | this.performSearch(suggestion); 277 | }; 278 | 279 | performSearch = (search: string): Promise => { 280 | if (!this.componentIsMounted) { 281 | return Promise.resolve(); 282 | } 283 | 284 | return this.client.search(search).then(page => { 285 | this.mountedSetState({ page: 0, pages: [page], searching: false }); 286 | }).catch(() => { 287 | this.mountedSetState({ searching: false }); 288 | }); 289 | }; 290 | 291 | mountedSetState = (state: SetState): void => { 292 | if (this.componentIsMounted) { 293 | this.setState(state); 294 | } 295 | }; 296 | 297 | makeClient(): Client { 298 | const { base, token, locale, mediaFilter, contentFilter, defaultResults, limit } = this.props; 299 | 300 | return new Client({ 301 | base, 302 | token, 303 | locale, 304 | mediaFilter, 305 | contentFilter, 306 | defaultResults, 307 | limit 308 | }); 309 | } 310 | 311 | focus(): void { 312 | const input = this.inputRef.current; 313 | 314 | if (input) { 315 | input.focus(); 316 | } 317 | } 318 | 319 | render(): React.ReactElement { 320 | const { contentRef, onSelect, searchPlaceholder } = this.props; 321 | const { 322 | autoComplete, page, pages, search, searching, suggestions 323 | } = this.state; 324 | 325 | return ( 326 | 342 | ); 343 | } 344 | } 345 | 346 | export { Result } from "./TenorAPI"; 347 | export default Tenor; 348 | -------------------------------------------------------------------------------- /src/__tests__/Tenor.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { mount, ReactWrapper } from "enzyme"; 3 | 4 | import * as TenorAPI from "../TenorAPI"; 5 | import Tenor, { defaultState } from "../Tenor"; 6 | import Result from "../Result"; 7 | import mockResults from "./mockResults"; 8 | import testServer from "./testServer"; 9 | 10 | const ARROW_LEFT_KEY = 37; 11 | const ARROW_RIGHT_KEY = 39; 12 | 13 | type TenorProps = React.ComponentProps; 14 | type TenorState = Tenor["state"]; 15 | 16 | type MountedTenor = ReactWrapper & { 17 | pressKey: (keyCode: number) => void; 18 | pressArrowLeftKey: () => void; 19 | pressArrowRightKey: () => void; 20 | }; 21 | 22 | type MountedTenorProps = Partial; 23 | type MountedTenorState = Partial; 24 | 25 | const mountTenor = (props: MountedTenorProps = {}, state: MountedTenorState = {}): MountedTenor => { 26 | const component = mount( 27 | 35 | ) as MountedTenor; 36 | 37 | component.setState({ ...defaultState, ...state }); 38 | 39 | component.pressKey = keyCode => { 40 | component.instance().handleWindowKeyDown({ 41 | ...new KeyboardEvent("keydown"), 42 | keyCode, 43 | metaKey: true, 44 | target: component.instance().contentRef.current, 45 | preventDefault: () => {} 46 | }); 47 | }; 48 | 49 | component.pressArrowLeftKey = () => component.pressKey(ARROW_LEFT_KEY); 50 | component.pressArrowRightKey = () => component.pressKey(ARROW_RIGHT_KEY); 51 | 52 | return component; 53 | }; 54 | 55 | test("performs searches", async () => { 56 | let selected: TenorAPI.Result | null = null; 57 | const onSelect = (result: TenorAPI.Result) => { 58 | selected = result; 59 | }; 60 | 61 | const search = "Happy"; 62 | const component = mountTenor({ onSelect }); 63 | component.find("input").simulate("change", { target: { value: search } }); 64 | 65 | component.update(); 66 | expect(component.state().search).toEqual(search); 67 | expect(component.find("svg")).toHaveLength(1); 68 | 69 | await component.instance().performSearch(search); 70 | 71 | component.update(); 72 | expect(component.find(Result)).toHaveLength(mockResults.search.length); 73 | 74 | component.find(Result).at(3).simulate("click"); 75 | expect(selected).toEqual(mockResults.search[3]); 76 | 77 | component.unmount(); 78 | }); 79 | 80 | test("dedups fast searches", () => { 81 | const component = mountTenor(); 82 | const previousSearches = testServer.requests.search; 83 | 84 | const search = "Happy"; 85 | search.split("").forEach((_, index) => { 86 | const value = search.slice(0, index + 1); 87 | component.find("input").simulate("change", { target: { value } }); 88 | }); 89 | 90 | return new Promise(resolve => { 91 | // Yeah this is not great, but if you're going to test setTimeout, 92 | // sometimes you just want to use setTimeout. 93 | setTimeout(() => { 94 | expect(testServer.requests.search).toEqual(previousSearches + 1); 95 | 96 | resolve(); 97 | }, 300); 98 | }); 99 | }); 100 | 101 | test("allows passing an initialSearch prop", async () => { 102 | const component = mountTenor({ initialSearch: "happy" }); 103 | 104 | await component.instance().performSearch(""); 105 | component.update(); 106 | 107 | expect(component.find(Result)).toHaveLength(mockResults.search.length); 108 | }); 109 | 110 | test("does not enqueue searches for empty inputs", () => { 111 | const component = mountTenor(); 112 | 113 | component.find("input").simulate("change", { target: { value: "" } }); 114 | 115 | expect(component.instance().timeout).toBe(null); 116 | }); 117 | 118 | test("handles the contentRef prop", () => { 119 | const contentRef = React.createRef(); 120 | const component = mountTenor({ contentRef }); 121 | 122 | expect(contentRef.current).not.toBe(null); 123 | component.unmount(); 124 | }); 125 | 126 | describe("suggestions", () => { 127 | test("handles clicking a suggestion", async () => { 128 | const component = mountTenor(); 129 | 130 | component.setState({ search: "test" }); 131 | await component.instance().fetchSuggestions("test"); 132 | component.update(); 133 | 134 | expect(component.find("Suggestion")).toHaveLength(5); 135 | 136 | component.find("Suggestion").at(2).find("button").simulate("click"); 137 | 138 | expect(component.state().search).toEqual(mockResults.search_suggestions[2]); 139 | await component.instance().performSearch(mockResults.search_suggestions[2]); 140 | }); 141 | 142 | test("clears the timeout", () => { 143 | const component = mountTenor(); 144 | component.setState({ search: "t", suggestions: ["test"] }); 145 | 146 | component.instance().client.search = () => Promise.resolve({ results: mockResults.search }); 147 | component.instance().timeout = setTimeout(() => {}, 1000); 148 | 149 | component.find("Suggestion").find("button").simulate("click"); 150 | expect(component.state().search).toEqual("test"); 151 | }); 152 | }); 153 | 154 | describe("tab completion", () => { 155 | const BACKSPACE_KEY = 8; 156 | const TAB_KEY = 9; 157 | 158 | test("handles tab completing the typeahead", async () => { 159 | const component = mountTenor(); 160 | 161 | component.setState({ search: "t" }); 162 | await component.instance().fetchAutoComplete("t"); 163 | component.update(); 164 | 165 | expect(component.find("AutoComplete")).toHaveLength(1); 166 | 167 | component.find("input").simulate("keyDown", { keyCode: TAB_KEY }); 168 | expect(component.state().search).toEqual(mockResults.autocomplete[0]); 169 | 170 | await component.instance().performSearch(mockResults.autocomplete[0]); 171 | }); 172 | 173 | test("ignores other key inputs", () => { 174 | const component = mountTenor(); 175 | 176 | component.find("input").simulate("keyDown", { keyCode: BACKSPACE_KEY }); 177 | expect(component.state().search).toEqual(""); 178 | }); 179 | 180 | test("ignores when the autoComplete matches the search", () => { 181 | const component = mountTenor(); 182 | component.setState({ autoComplete: "test", search: "test" }); 183 | 184 | component.find("input").simulate("keyDown", { keyCode: TAB_KEY }); 185 | expect(component.state().search).toEqual("test"); 186 | }); 187 | }); 188 | 189 | describe("auto close", () => { 190 | test("handles clicking outside the component", () => { 191 | const contentRef = React.createRef(); 192 | const component = mountTenor({ contentRef }); 193 | 194 | component.instance().handleWindowClick(new MouseEvent("click")); 195 | expect(component.state().search).toEqual(""); 196 | 197 | component.setState({ search: "t" }); 198 | component.instance().handleWindowClick({ ...new MouseEvent("click"), target: contentRef.current }); 199 | expect(component.state().search).toEqual("t"); 200 | 201 | component.instance().handleWindowClick(new MouseEvent("click")); 202 | expect(component.state().search).toEqual(""); 203 | }); 204 | 205 | test("clears the timeout", () => { 206 | const component = mountTenor(); 207 | component.setState({ search: "t" }); 208 | 209 | component.instance().timeout = setTimeout(() => {}, 1000); 210 | component.instance().handleWindowClick(new MouseEvent("click")); 211 | expect(component.state().search).toEqual(""); 212 | }); 213 | }); 214 | 215 | test("creates a new client when the token or base changes", () => { 216 | const component = mountTenor({ base: "https://example.com" }); 217 | 218 | component.setProps({ token: "other-token" }); 219 | expect(component.instance().client.token).toEqual("other-token"); 220 | 221 | component.setProps({ base: "https://other-example.com" }); 222 | expect(component.instance().client.base).toEqual("https://other-example.com"); 223 | }); 224 | 225 | test("handles when the search returns an error", () => { 226 | const component = mountTenor(); 227 | component.instance().client.search = () => Promise.reject(new Error("error")); 228 | 229 | component.instance().performSearch("test"); 230 | expect(component.state().searching).toBe(false); 231 | }); 232 | 233 | test("unmounts cleanly", async () => { 234 | const component = mountTenor(); 235 | const instance = component.instance(); 236 | 237 | setTimeout(() => instance.mountedSetState({ search: "foobar" }), 100); 238 | component.unmount(); 239 | 240 | await new Promise(resolve => { 241 | setTimeout(resolve, 100); 242 | }); 243 | }); 244 | 245 | test("searchPlaceholder", () => { 246 | const searchPlaceholder = "Search GIFs!!"; 247 | const component = mountTenor({ searchPlaceholder }); 248 | 249 | const input = component.find("input"); 250 | expect(input.prop("placeholder")).toEqual(searchPlaceholder); 251 | }); 252 | 253 | describe("pagination", () => { 254 | test("paging left", () => { 255 | const component = mountTenor(); 256 | expect(component.state().page).toEqual(0); 257 | 258 | component.instance().handlePageLeft(); 259 | expect(component.state().page).toEqual(0); 260 | 261 | component.setState({ page: 1 }); 262 | component.instance().handlePageLeft(); 263 | expect(component.state().page).toEqual(0); 264 | }); 265 | 266 | test("paging left with the keys", () => { 267 | const component = mountTenor({}, { page: 1 }); 268 | 269 | component.pressArrowLeftKey(); 270 | 271 | expect(component.state().page).toEqual(0); 272 | }); 273 | 274 | test("paging right when not at the end", () => { 275 | const component = mountTenor({}, { 276 | page: 0, 277 | pages: [ 278 | { results: mockResults.search, next: "12" }, 279 | { results: mockResults.search, next: "24" } 280 | ], 281 | search: "test", 282 | searching: false 283 | }); 284 | 285 | component.instance().handlePageRight(); 286 | expect(component.state().page).toEqual(1); 287 | }); 288 | 289 | test("paging right when at the end", async () => { 290 | const component = mountTenor({ token: "token" }, { search: "test" }); 291 | 292 | await component.instance().performSearch("test"); 293 | component.update(); 294 | 295 | await component.instance().handlePageRight(); 296 | expect(component.state().page).toEqual(1); 297 | }); 298 | 299 | test("paging right with the keys", () => { 300 | const component = mountTenor({}, { 301 | page: 0, 302 | pages: [ 303 | { results: mockResults.search, next: "12" }, 304 | { results: mockResults.search, next: "24" } 305 | ], 306 | search: "test", 307 | searching: false 308 | }); 309 | 310 | component.pressArrowRightKey(); 311 | 312 | expect(component.state().page).toEqual(1); 313 | }); 314 | 315 | test("ignores other key presses", () => { 316 | const component = mountTenor({}, { page: 1 }); 317 | 318 | component.instance().handleWindowKeyDown({ 319 | ...new KeyboardEvent("keydown"), 320 | keyCode: ARROW_LEFT_KEY, 321 | metaKey: true, 322 | target: document.body, 323 | preventDefault() {} 324 | }); 325 | 326 | expect(component.state().page).toEqual(1); 327 | }); 328 | 329 | test("does not page right if currently searching", () => { 330 | const component = mountTenor({}, { 331 | page: 0, 332 | pages: [ 333 | { results: mockResults.search, next: "12" }, 334 | { results: mockResults.search, next: "24" } 335 | ], 336 | search: "test", 337 | searching: true 338 | }); 339 | 340 | component.pressArrowRightKey(); 341 | 342 | expect(component.state().page).toEqual(0); 343 | }); 344 | 345 | test("resets the searching state if the pagination call fails", () => { 346 | const component = mountTenor({}, { 347 | page: 0, 348 | pages: [{ results: mockResults.search, next: "12" }], 349 | search: "test", 350 | searching: false 351 | }); 352 | 353 | component.instance().client.search = () => Promise.reject(new Error("error")); 354 | component.pressArrowRightKey(); 355 | 356 | expect(component.state().page).toEqual(0); 357 | expect(component.state().searching).toBe(false); 358 | }); 359 | 360 | test("does not increment page if the next page has no results", () => { 361 | const component = mountTenor({}, { 362 | page: 0, 363 | pages: [{ results: mockResults.search, next: "12" }], 364 | search: "test", 365 | searching: false 366 | }); 367 | 368 | component.instance().client.search = () => Promise.resolve({ results: [] }); 369 | component.pressArrowRightKey(); 370 | 371 | expect(component.state().page).toEqual(0); 372 | expect(component.state().searching).toBe(false); 373 | }); 374 | }); 375 | -------------------------------------------------------------------------------- /docs/index.js: -------------------------------------------------------------------------------- 1 | !function(e){var t={};function n(r){if(t[r])return t[r].exports;var l=t[r]={i:r,l:!1,exports:{}};return e[r].call(l.exports,l,l.exports,n),l.l=!0,l.exports}n.m=e,n.c=t,n.d=function(e,t,r){n.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:r})},n.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},n.t=function(e,t){if(1&t&&(e=n(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var r=Object.create(null);if(n.r(r),Object.defineProperty(r,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var l in e)n.d(r,l,function(t){return e[t]}.bind(null,l));return r},n.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return n.d(t,"a",t),t},n.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},n.p="",n(n.s=2)}([function(e,t,n){"use strict";e.exports=n(3)},function(e,t,n){"use strict"; 2 | /* 3 | object-assign 4 | (c) Sindre Sorhus 5 | @license MIT 6 | */var r=Object.getOwnPropertySymbols,l=Object.prototype.hasOwnProperty,a=Object.prototype.propertyIsEnumerable;function o(e){if(null==e)throw new TypeError("Object.assign cannot be called with null or undefined");return Object(e)}e.exports=function(){try{if(!Object.assign)return!1;var e=new String("abc");if(e[5]="de","5"===Object.getOwnPropertyNames(e)[0])return!1;for(var t={},n=0;n<10;n++)t["_"+String.fromCharCode(n)]=n;if("0123456789"!==Object.getOwnPropertyNames(t).map((function(e){return t[e]})).join(""))return!1;var r={};return"abcdefghijklmnopqrst".split("").forEach((function(e){r[e]=e})),"abcdefghijklmnopqrst"===Object.keys(Object.assign({},r)).join("")}catch(e){return!1}}()?Object.assign:function(e,t){for(var n,i,u=o(e),c=1;cR.length&&R.push(e)}function I(e,t,n){return null==e?0:function e(t,n,r,l){var i=typeof t;"undefined"!==i&&"boolean"!==i||(t=null);var u=!1;if(null===t)u=!0;else switch(i){case"string":case"number":u=!0;break;case"object":switch(t.$$typeof){case a:case o:u=!0}}if(u)return r(l,t,""===n?"."+F(t,0):n),1;if(u=0,n=""===n?".":n+":",Array.isArray(t))for(var c=0;c