├── .eslintrc ├── .github └── workflows │ ├── publish.yml │ └── test.yml ├── .gitignore ├── .storybook ├── global.css ├── main.js └── preview.js ├── .travis.yml ├── README.md ├── jest-puppeteer.config.js ├── jest.config.js ├── jest ├── jest-extend.js ├── jest-setup.js ├── jest-teardown.js └── jest.d.ts ├── package.json ├── pnpm-lock.yaml ├── src ├── Introduction.stories.mdx ├── cookie-banner │ ├── CookieBanner.stories.mdx │ ├── CookieBanner.test.js │ ├── index.js │ └── story.css ├── dropdown-menu │ ├── DropdownMenu.stories.mdx │ ├── index.js │ ├── index.test.js │ └── story.css ├── global.css ├── index.html ├── index.js ├── modal-dialog │ ├── ModalDialog.stories.mdx │ ├── index.js │ ├── index.test.js │ └── story.css ├── nav-tabs │ ├── NavTabs.stories.mdx │ ├── index.js │ ├── index.test.js │ └── story.css ├── scroll-top │ ├── ScrollTop.stories.mdx │ ├── ScrollTop.test.js │ ├── index.js │ └── story.css ├── textarea-autogrow │ ├── TextareaAutogrow.stories.mdx │ ├── index.js │ ├── index.test.js │ └── story.css └── utils │ ├── dom.js │ ├── jest.js │ └── time.js └── yarn.lock /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "node": true 5 | }, 6 | "globals": { 7 | "page": "readonly" 8 | } 9 | } -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | push: 5 | tags: 6 | - 0.* 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | node-version: [12.x] 14 | steps: 15 | - uses: actions/checkout@v1 16 | - name: Use Node.js ${{ matrix.node-version }} 17 | uses: actions/setup-node@v1 18 | with: 19 | node-version: ${{ matrix.node-version }} 20 | - run: yarn 21 | - name: lint 22 | run: yarn run lint 23 | - name: test 24 | run: yarn run test 25 | env: 26 | CI: true 27 | NOVE_ENV: test 28 | - name: npm publish 29 | run: | 30 | npm config set //registry.npmjs.org/:_authToken=$NODE_AUTH_TOKEN 31 | npm publish --access=public 32 | env: 33 | CI: true 34 | NODE_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }} 35 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push] 4 | 5 | jobs: 6 | test: 7 | runs-on: ${{ matrix.os }} 8 | strategy: 9 | matrix: 10 | os: [windows-latest, ubuntu-latest, macos-latest] 11 | node-version: [12.x] 12 | steps: 13 | - uses: actions/checkout@v1 14 | - name: Use Node.js ${{ matrix.node-version }} 15 | uses: actions/setup-node@v1 16 | with: 17 | node-version: ${{ matrix.node-version }} 18 | - run: yarn 19 | - name: lint 20 | run: yarn run lint 21 | - name: test 22 | run: yarn run test 23 | env: 24 | CI: true 25 | NOVE_ENV: test 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | storybook-static 3 | -------------------------------------------------------------------------------- /.storybook/global.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: sans-serif; 3 | color: #374151; 4 | --accent: #007bff; 5 | } 6 | 7 | .stack { 8 | display: grid; 9 | grid-template-columns: 1fr; 10 | grid-gap: 16px; 11 | } 12 | 13 | .card { 14 | background-color: #fff; 15 | padding: 16px; 16 | border: 1px solid #00000020; 17 | margin-bottom: 50px; 18 | } 19 | 20 | .card header { 21 | background-color: #00000008; 22 | border-bottom: 1px solid #00000020; 23 | margin: -16px -16px 16px; 24 | padding: 12px; 25 | font-weight: bold; 26 | } 27 | 28 | button { 29 | box-shadow: 0 1px 2px 0 #0000000d; 30 | padding: 0.5rem 0.75rem; 31 | border: solid 1px #d1d5db; 32 | background-color: #fff; 33 | font-weight: 500; 34 | border-radius: 4px; 35 | cursor: pointer; 36 | transition: background-color 0.3s; 37 | } 38 | 39 | button:hover { 40 | background-color: #e8e8e8; 41 | border-color: #b6b9c1; 42 | } 43 | -------------------------------------------------------------------------------- /.storybook/main.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | stories: ['../src/**/*.stories.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'], 3 | addons: ['@storybook/addon-links', '@storybook/addon-essentials'] 4 | } 5 | -------------------------------------------------------------------------------- /.storybook/preview.js: -------------------------------------------------------------------------------- 1 | import './global.css' 2 | 3 | export const parameters = { 4 | actions: { argTypesRegex: '^on[A-Z].*' }, 5 | viewMode: 'docs', 6 | controls: { 7 | hideNoControlsWarning: true 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 12 4 | - 13 5 | - 'stable' 6 | script: 7 | - yarn run lint 8 | - yarn run test 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Simple Basic CustomElements 2 | 3 | [![npm](https://img.shields.io/npm/v/@sb-elements/all.svg)](http://npm.im/@sb-elements/all) 4 | [![Build Status](https://travis-ci.org/SimpleBasicElements/Elements.svg?branch=master)](https://travis-ci.org/SimpleBasicElements/Elements) 5 | [![Build Status](https://github.com/SimpleBasicElements/Elements/workflows/Test/badge.svg)](https://github.com/SimpleBasicElements/Elements/actions) 6 | 7 | The goal of this project is to create a library of CustomElement to stop reinventing the wheel (starting by reinventing the wheel using CustomElements). 8 | 9 | [Storybook](https://simplebasicelements.github.io/Elements/) 10 | -------------------------------------------------------------------------------- /jest-puppeteer.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | launch: { 3 | headless: process.env.HEADLESS !== 'false' 4 | }, 5 | browser: 'chromium' 6 | } 7 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | // For a detailed explanation regarding each configuration property, visit: 2 | // https://jestjs.io/docs/en/configuration.html 3 | 4 | module.exports = { 5 | // All imported modules in your tests should be mocked automatically 6 | // automock: false, 7 | 8 | // Stop running tests after `n` failures 9 | // bail: 0, 10 | 11 | // Respect "browser" field in package.json when resolving modules 12 | // browser: false, 13 | 14 | // The directory where Jest should store its cached dependency information 15 | // cacheDirectory: "/tmp/jest_rs", 16 | 17 | // Automatically clear mock calls and instances between every test 18 | // clearMocks: false, 19 | 20 | // Indicates whether the coverage information should be collected while executing the test 21 | // collectCoverage: false, 22 | 23 | // An array of glob patterns indicating a set of files for which coverage information should be collected 24 | // collectCoverageFrom: undefined, 25 | 26 | // The directory where Jest should output its coverage files 27 | // coverageDirectory: undefined, 28 | 29 | // An array of regexp pattern strings used to skip coverage collection 30 | // coveragePathIgnorePatterns: [ 31 | // "/node_modules/" 32 | // ], 33 | 34 | // A list of reporter names that Jest uses when writing coverage reports 35 | // coverageReporters: [ 36 | // "json", 37 | // "text", 38 | // "lcov", 39 | // "clover" 40 | // ], 41 | 42 | // An object that configures minimum threshold enforcement for coverage results 43 | // coverageThreshold: undefined, 44 | 45 | // A path to a custom dependency extractor 46 | // dependencyExtractor: undefined, 47 | 48 | // Make calling deprecated APIs throw helpful error messages 49 | // errorOnDeprecated: false, 50 | 51 | // Force coverage collection from ignored files using an array of glob patterns 52 | // forceCoverageMatch: [], 53 | 54 | // A path to a module which exports an async function that is triggered once before all test suites 55 | globalSetup: './jest/jest-setup.js', 56 | 57 | // A path to a module which exports an async function that is triggered once after all test suites 58 | globalTeardown: './jest/jest-teardown.js', 59 | 60 | setupFilesAfterEnv: ['./jest/jest-extend.js'], 61 | 62 | // A set of global variables that need to be available in all test environments 63 | // globals: {}, 64 | 65 | // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. 66 | // maxWorkers: "50%", 67 | 68 | // An array of directory names to be searched recursively up from the requiring module's location 69 | // moduleDirectories: [ 70 | // "node_modules" 71 | // ], 72 | 73 | // An array of file extensions your modules use 74 | // moduleFileExtensions: [ 75 | // "js", 76 | // "json", 77 | // "jsx", 78 | // "ts", 79 | // "tsx", 80 | // "node" 81 | // ], 82 | 83 | // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module 84 | // moduleNameMapper: {}, 85 | 86 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader 87 | // modulePathIgnorePatterns: [], 88 | 89 | // Activates notifications for test results 90 | // notify: false, 91 | 92 | // An enum that specifies notification mode. Requires { notify: true } 93 | // notifyMode: "failure-change", 94 | 95 | // A preset that is used as a base for Jest's configuration 96 | preset: 'jest-puppeteer' 97 | 98 | // Run tests from one or more projects 99 | // projects: undefined, 100 | 101 | // Use this configuration option to add custom reporters to Jest 102 | // reporters: undefined, 103 | 104 | // Automatically reset mock state between every test 105 | // resetMocks: false, 106 | 107 | // Reset the module registry before running each individual test 108 | // resetModules: false, 109 | 110 | // A path to a custom resolver 111 | // resolver: undefined, 112 | 113 | // Automatically restore mock state between every test 114 | // restoreMocks: false, 115 | 116 | // The root directory that Jest should scan for tests and modules within 117 | // rootDir: undefined, 118 | 119 | // A list of paths to directories that Jest should use to search for files in 120 | // roots: [ 121 | // "" 122 | // ], 123 | 124 | // Allows you to use a custom runner instead of Jest's default test runner 125 | // runner: "jest-runner", 126 | 127 | // The paths to modules that run some code to configure or set up the testing environment before each test 128 | // setupFiles: [], 129 | 130 | // A list of paths to modules that run some code to configure or set up the testing framework before each test 131 | // setupFilesAfterEnv: [], 132 | 133 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing 134 | // snapshotSerializers: [], 135 | 136 | // The test environment that will be used for testing 137 | // testEnvironment: "jest-environment-jsdom", 138 | 139 | // Options that will be passed to the testEnvironment 140 | // testEnvironmentOptions: {}, 141 | 142 | // Adds a location field to test results 143 | // testLocationInResults: false, 144 | 145 | // The glob patterns Jest uses to detect test files 146 | // testMatch: [ 147 | // "**/__tests__/**/*.[jt]s?(x)", 148 | // "**/?(*.)+(spec|test).[tj]s?(x)" 149 | // ], 150 | 151 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped 152 | // testPathIgnorePatterns: [ 153 | // "/node_modules/" 154 | // ], 155 | 156 | // The regexp pattern or array of patterns that Jest uses to detect test files 157 | // testRegex: [], 158 | 159 | // This option allows the use of a custom results processor 160 | // testResultsProcessor: undefined, 161 | 162 | // This option allows use of a custom test runner 163 | // testRunner: "jasmine2", 164 | 165 | // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href 166 | // testURL: "http://localhost", 167 | 168 | // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout" 169 | // timers: "real", 170 | 171 | // A map from regular expressions to paths to transformers 172 | // transform: undefined, 173 | 174 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation 175 | // transformIgnorePatterns: [ 176 | // "/node_modules/" 177 | // ], 178 | 179 | // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them 180 | // unmockedModulePathPatterns: undefined, 181 | 182 | // Indicates whether each individual test should be reported during the run 183 | // verbose: undefined, 184 | 185 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode 186 | // watchPathIgnorePatterns: [], 187 | 188 | // Whether to use watchman for file crawling 189 | // watchman: true, 190 | } 191 | -------------------------------------------------------------------------------- /jest/jest-extend.js: -------------------------------------------------------------------------------- 1 | // Check if an element is visible 2 | expect.extend({ 3 | /** 4 | * @param {ElementHandle} received 5 | * @return {{pass: boolean, message: (function(): string)}|{pass: boolean, message: (function(): string)}} 6 | */ 7 | async toBeVisible (received) { 8 | if (typeof received === 'string') { 9 | received = await page.$(received) 10 | } 11 | const name = await received.evaluate( 12 | el => el.tagName.toLowerCase() + (el.id ? '#' + el.id : '') 13 | ) 14 | const visible = await received.evaluate(el => { 15 | return ( 16 | el.getAttribute('hidden') === null && 17 | (el.getAttribute('aria-hidden') === null || 18 | el.getAttribute('aria-hidden') === 'false') 19 | ) 20 | }) 21 | return { 22 | message: () => `expected <${name}> not to be hidden`, 23 | pass: visible 24 | } 25 | }, 26 | 27 | /** 28 | * @param {ElementHandle} received 29 | * @return {{pass: boolean, message: (function(): string)}|{pass: boolean, message: (function(): string)}} 30 | */ 31 | async toBeHidden (received) { 32 | if (typeof received === 'string') { 33 | received = await page.$(received) 34 | } 35 | const name = await received.evaluate( 36 | el => el.tagName.toLowerCase() + (el.id ? '#' + el.id : '') 37 | ) 38 | const hidden = await received.evaluate(el => { 39 | return ( 40 | ['hidden', ''].includes(el.getAttribute('hidden')) || 41 | el.getAttribute('aria-hidden') === 'true' 42 | ) 43 | }) 44 | return { 45 | message: () => `expected <${name}> to be hidden`, 46 | pass: hidden 47 | } 48 | }, 49 | 50 | /** 51 | * @param {string} received 52 | * @param {string} html 53 | * @return {{pass: boolean, message: (function(): string)}|{pass: boolean, message: (function(): string)}} 54 | */ 55 | async toHaveHTML (received, expectedHTML) { 56 | const html = await (await page.$(received)).evaluate(e => e.innerHTML) 57 | const pass = html === expectedHTML 58 | 59 | return { 60 | pass, 61 | message: () => { 62 | return ( 63 | this.utils.matcherHint('toHaveHTML', received, expectedHTML) + 64 | '\n\n' + 65 | `Expected: ${this.utils.printExpected(expectedHTML)}\n` + 66 | `Received: ${this.utils.printReceived(html)}` 67 | ) 68 | } 69 | } 70 | }, 71 | 72 | /** 73 | * @param {string} selector 74 | * @param {string} attribute 75 | * @param {any} expectedValue 76 | * @return {{pass: boolean, message: (function(): string)}|{pass: boolean, message: (function(): string)}} 77 | */ 78 | async toHaveAttribute (selector, attribute, expectedValue) { 79 | const value = await page.$eval(selector, (element, attribute) => element?.getAttribute(attribute), attribute) 80 | const pass = value === expectedValue 81 | 82 | return { 83 | pass, 84 | message: () => { 85 | return ( 86 | this.utils.matcherHint('toHaveAttribute', selector, attribute) + 87 | '\n\n' + 88 | `Expected: ${this.utils.printExpected(expectedValue)}\n` + 89 | `Received: ${this.utils.printReceived(value)}` 90 | ) 91 | } 92 | } 93 | }, 94 | 95 | /** 96 | * @param {string} selector 97 | * @return {{pass: boolean, message: (function(): string)}|{pass: boolean, message: (function(): string)}} 98 | */ 99 | async toBeFocused (selector) { 100 | const pass = await page.$eval(selector, (element) => element === document.activeElement) 101 | 102 | return { 103 | pass, 104 | message: () => { 105 | return ( 106 | this.utils.matcherHint('toBeFocused', selector) 107 | ) 108 | } 109 | } 110 | } 111 | 112 | }) 113 | -------------------------------------------------------------------------------- /jest/jest-setup.js: -------------------------------------------------------------------------------- 1 | const { setup: setupDevServer } = require('jest-dev-server') 2 | const { setup: setupPuppeteer } = require('jest-environment-puppeteer') 3 | 4 | module.exports = async function globalSetup (globalConfig) { 5 | await setupPuppeteer(globalConfig) 6 | // Wait for storybook server to be ready 7 | await setupDevServer({ 8 | command: `npm run storybook`, 9 | launchTimeout: 50000, 10 | protocol: 'http', 11 | port: 6006, 12 | waitOnScheme: { 13 | resources: ['http://localhost:6006/iframe.html'] 14 | } 15 | }) 16 | } 17 | -------------------------------------------------------------------------------- /jest/jest-teardown.js: -------------------------------------------------------------------------------- 1 | const { teardown: teardownDevServer } = require('jest-dev-server') 2 | const { teardown: teardownPuppeteer } = require('jest-environment-puppeteer') 3 | 4 | module.exports = async function globalTeardown (globalConfig) { 5 | await teardownPuppeteer(globalConfig) 6 | await teardownDevServer() 7 | } 8 | -------------------------------------------------------------------------------- /jest/jest.d.ts: -------------------------------------------------------------------------------- 1 | import { ElementHandle } from 'puppeteer' 2 | 3 | declare global { 4 | namespace jest { 5 | interface Matchers { 6 | toBeVisible(): Promise; 7 | toBeHidden(): Promise; 8 | toHaveHTML(expectedHTML: string): Promise; 9 | toClick(selector: string): Promise; 10 | toHaveAttribute(attribute: string, value: any): Promise; 11 | toBeFocused(): Promise; 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@sb-elements/all", 3 | "version": "0.3.4", 4 | "description": "Library of unopinionated components", 5 | "main": "src/index.js", 6 | "author": "jonathan", 7 | "license": "MIT", 8 | "files": [ 9 | "src/**/*.js" 10 | ], 11 | "devDependencies": { 12 | "@babel/core": "^7.12.10", 13 | "@storybook/addon-actions": "^6.1.11", 14 | "@storybook/addon-docs": "^6.1.11", 15 | "@storybook/addon-essentials": "^6.1.11", 16 | "@storybook/addon-links": "^6.1.11", 17 | "@storybook/html": "^6.1.11", 18 | "@types/jest": "^25.1.4", 19 | "@types/jest-environment-puppeteer": "^4.3.1", 20 | "@types/puppeteer": "^2.0.1", 21 | "babel-loader": "^8.2.2", 22 | "gh-pages": "^3.1.0", 23 | "http-server": "^0.12.1", 24 | "jest": "^25.2.4", 25 | "jest-dev-server": "^4.4.0", 26 | "jest-puppeteer": "^4.4.0", 27 | "prettier": "^2.6.0", 28 | "puppeteer": "^5.5.0" 29 | }, 30 | "scripts": { 31 | "serve": "http-server ./src -p 8080", 32 | "format": "prettier-standard 'src/**/*.{js,css,html}' --format", 33 | "lint": "prettier-standard 'src/**/*.{js,css,html}' --lint", 34 | "test": "jest", 35 | "storybook": "start-storybook -p 6006 --quiet --ci", 36 | "build-storybook": "build-storybook", 37 | "gh": "npm run build-storybook && gh-pages -d storybook-static" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Introduction.stories.mdx: -------------------------------------------------------------------------------- 1 | import { Meta } from '@storybook/addon-docs/blocks'; 2 | 3 | 4 | 5 | # Simple Basic CustomElements 6 | 7 | [![npm](https://img.shields.io/npm/v/@sb-elements/all.svg)](http://npm.im/@sb-elements/all) 8 | [![Build Status](https://travis-ci.org/SimpleBasicElements/Elements.svg?branch=master)](https://travis-ci.org/SimpleBasicElements/Elements) 9 | [![Build Status](https://github.com/SimpleBasicElements/Elements/workflows/Test/badge.svg)](https://github.com/SimpleBasicElements/Elements/actions) 10 | 11 | The goal of this project is to create a library of CustomElement to stop reinventing the wheel (starting by reinventing the wheel using CustomElements). 12 | -------------------------------------------------------------------------------- /src/cookie-banner/CookieBanner.stories.mdx: -------------------------------------------------------------------------------- 1 | import { Meta, Story, Canvas, ArgsTable } from '@storybook/addon-docs/blocks'; 2 | import ScrollTop from './index'; 3 | import './story.css' 4 | import CookieBanner from './index' 5 | 6 | 10 | 11 | # Cookie banner elements `` 12 | 13 | This component displays a cookie banner and emit events when the cookie is accepted or refused. 14 | 15 | ### Usage 16 | 17 | You can have a simple accept / reject cookie banner 18 | 19 | ```html 20 | 21 |

Whatever content you may want

22 | 23 | 24 |
25 | ``` 26 | 27 | Or you can use a form for multi level cookie acceptance. The FormData will be stored in the cookie 28 | 29 | ```html 30 | 31 |

Whatever content you may want

32 |
33 |

34 | Required cookie 35 | Tracking cookies 36 |

37 |

38 | 39 | 40 |

41 |
42 |
43 | ``` 44 | 45 | export const Template = ({ label, ...args }) => { 46 | try { 47 | customElements.define('cookie-banner', CookieBanner) 48 | } catch (e) { 49 | // do nothing this is custom elements 50 | } 51 | const div = document.createElement('div') 52 | div.innerHTML = ` 53 | 77 |
78 | ` 79 | const reset = div.querySelector('button[type="reset"]') 80 | reset.addEventListener('click', () => { 81 | document.cookie = 82 | CookieBanner.cookieName + 83 | '= ; expires = Thu, 01 Jan 1970 00:00:00 GMT; path=/' 84 | document.location.reload() 85 | }) 86 | const banner = div.querySelector('cookie-banner') 87 | banner.addEventListener('accept', args.onAccept) 88 | banner.addEventListener('reject', args.onReject) 89 | return div 90 | }; 91 | 92 | 93 | 94 | {Template.bind({})} 95 | 96 | 97 | -------------------------------------------------------------------------------- /src/cookie-banner/CookieBanner.test.js: -------------------------------------------------------------------------------- 1 | require('expect-puppeteer') 2 | 3 | beforeEach(async () => { 4 | const cookies = await page.cookies() 5 | for (const cookie of cookies) { 6 | await page.deleteCookie(cookie) 7 | } 8 | await page.goto( 9 | `http://localhost:6006/iframe.html?id=cookiebanner--default-story&viewMode=story` 10 | ) 11 | }) 12 | 13 | describe('#cookie-banner', () => { 14 | it('should hide on action', async () => { 15 | await page.keyboard.press('Tab') 16 | await page.keyboard.press('Escape') 17 | await expect('#banner').toBeHidden() 18 | }) 19 | describe('::cookies', () => { 20 | it('should remember user refusal', async () => { 21 | await (await page.$('[data-reject]')).click() 22 | const cookies = await page.cookies() 23 | expect(cookies).toHaveLength(1) 24 | expect(cookies[0].name).toBe('cookieConsent') 25 | expect(cookies[0].value).toBe('false') 26 | }) 27 | it('should remember user accept', async () => { 28 | await (await page.$('[data-accept]')).click() 29 | const cookies = await page.cookies() 30 | expect(cookies).toHaveLength(1) 31 | expect(cookies[0].name).toBe('cookieConsent') 32 | expect(cookies[0].value).toBe('{}') 33 | }) 34 | it('should remember user form choices', async () => { 35 | await expect(page).toClick('[name=tracking]') 36 | await expect(page).toClick('[data-accept]') 37 | const cookies = await page.cookies() 38 | expect(cookies).toHaveLength(1) 39 | expect(cookies[0].name).toBe('cookieConsent') 40 | expect(cookies[0].value).toBe('{"tracking":"1"}') 41 | }) 42 | }) 43 | }) 44 | -------------------------------------------------------------------------------- /src/cookie-banner/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Bind an eventlistener on multiple elements 3 | * 4 | * @param {NodeListOf} elements 5 | * @param {string} elements 6 | * @param {function} callback 7 | */ 8 | function addEventListeners (elements, eventName, callback) { 9 | Array.from(elements).forEach(function (el) { 10 | el.addEventListener(eventName, function (e) { 11 | e.preventDefault() 12 | callback() 13 | }) 14 | }) 15 | } 16 | 17 | /** 18 | * @param {object} value 19 | */ 20 | function writeCookie (value) { 21 | document.cookie = `${CookieBanner.cookieName}=${JSON.stringify( 22 | value 23 | )};max-age=${CookieBanner.expires};path=${CookieBanner.path}` 24 | } 25 | 26 | /** 27 | * @param {object} value 28 | */ 29 | function readCookie () { 30 | const prefix = CookieBanner.cookieName + '=' 31 | for (const cookie of document.cookie.split(/; */)) { 32 | if (cookie.startsWith(prefix)) { 33 | return JSON.parse(cookie.replace(prefix, '')) 34 | } 35 | } 36 | return null 37 | } 38 | 39 | export default class CookieBanner extends HTMLElement { 40 | connectedCallback () { 41 | if (readCookie() !== null) { 42 | if (this.parentElement) { 43 | this.parentElement.removeChild(this) 44 | } else { 45 | this.hide() 46 | } 47 | return 48 | } 49 | this.removeAttribute('hidden') 50 | this.removeAttribute('aria-hidden') 51 | this.setAttribute('tabindex', '0') 52 | this.setAttribute('role', 'dialog') 53 | this.setAttribute('aria-live', 'polite') 54 | this.addEventListener('keydown', this.onKeyDown.bind(this)) 55 | addEventListeners( 56 | this.querySelectorAll('[data-accept]'), 57 | 'click', 58 | this.accept.bind(this) 59 | ) 60 | addEventListeners( 61 | this.querySelectorAll('[data-reject]'), 62 | 'click', 63 | this.reject.bind(this) 64 | ) 65 | addEventListeners( 66 | this.querySelectorAll('form'), 67 | 'submit', 68 | this.accept.bind(this) 69 | ) 70 | } 71 | 72 | disconnectedCallback () { 73 | document.removeEventListener('keydown', this.onKeyDown) 74 | } 75 | 76 | /** 77 | * @param {KeyboardEvent} e 78 | */ 79 | onKeyDown (e) { 80 | if (e.key === 'Escape') { 81 | this.reject() 82 | } 83 | } 84 | 85 | reject () { 86 | this.dispatchEvent(new CustomEvent('reject')) 87 | this.hide() 88 | writeCookie(false) 89 | } 90 | 91 | accept () { 92 | /** @var {HTMLFormElement|null} form */ 93 | const form = this.querySelector('form') 94 | let detail = {} 95 | if (form !== null) { 96 | detail = Object.fromEntries(new FormData(form).entries()) 97 | } 98 | this.dispatchEvent( 99 | new CustomEvent('accept', { 100 | detail 101 | }) 102 | ) 103 | writeCookie(detail) 104 | this.hide() 105 | } 106 | 107 | hide () { 108 | this.removeAttribute('tabindex') 109 | this.setAttribute('hidden', 'hidden') 110 | this.setAttribute('aria-hidden', 'true') 111 | document.removeEventListener('keydown', this.onKeyDown) 112 | } 113 | 114 | /** 115 | * Check if we have the user consent 116 | * 117 | * @return {false|object} The object contains the data accepted by the user 118 | */ 119 | static hasConsent () { 120 | const cookie = readCookie() 121 | if (cookie === null || cookie === false) { 122 | return false 123 | } 124 | return cookie 125 | } 126 | } 127 | 128 | CookieBanner.cookieName = 'cookieConsent' 129 | CookieBanner.expires = 31104000000 130 | CookieBanner.path = '/' 131 | 132 | if (window.autoDefineComponent !== undefined) { 133 | customElements.define('cookie-banner', CookieBanner) 134 | } 135 | -------------------------------------------------------------------------------- /src/cookie-banner/story.css: -------------------------------------------------------------------------------- 1 | cookie-banner { 2 | display: block; 3 | width: 100%; 4 | padding: 10px; 5 | background-color: rgba(0, 0, 0, 0.8); 6 | color: #fff; 7 | margin-top: auto; 8 | } 9 | 10 | cookie-banner[hidden] { 11 | display: none; 12 | } 13 | -------------------------------------------------------------------------------- /src/dropdown-menu/DropdownMenu.stories.mdx: -------------------------------------------------------------------------------- 1 | import {Meta, Story, Canvas, ArgsTable} from '@storybook/addon-docs/blocks'; 2 | import {DropdownMenu, DropdownMenuContent} from './index'; 3 | import './story.css' 4 | 5 | 9 | 10 | export const Template = ({label, ...args}) => { 11 | try { 12 | customElements.define('dropdown-menu', DropdownMenu) 13 | customElements.define('dropdown-menu-content', DropdownMenuContent) 14 | } catch (e) { 15 | // do nothing this is custom elements 16 | } 17 | const div = document.createElement('div') 18 | div.style.setProperty('height', '230px') 19 | div.innerHTML = ` 20 | 21 | 22 | 32 | 33 | ` 34 | return div 35 | }; 36 | 37 | 38 | 39 | {Template.bind({})} 40 | 41 | 42 | -------------------------------------------------------------------------------- /src/dropdown-menu/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Create a dropdown menu 3 | * @property {HTMLElement} content 4 | * @property {HTMLButtonElement} button 5 | */ 6 | import { focusableElements } from "../utils/dom"; 7 | 8 | /** 9 | * @property {HTMLButtonElement} button 10 | * @property {DropdownMenuContent} content 11 | */ 12 | export class DropdownMenu extends HTMLElement { 13 | connectedCallback() { 14 | this.content = this.querySelector("dropdown-menu-content"); 15 | this.button = this.firstElementChild; 16 | if (!this.content || !this.button || this.button.tagName !== "BUTTON") { 17 | throw new Error("Dropdown structure not expected"); 18 | } 19 | this.button.addEventListener("click", (e) => { 20 | e.stopPropagation(); 21 | e.preventDefault(); 22 | if (this.isOpen) { 23 | this.close(); 24 | } else { 25 | this.open(); 26 | } 27 | }); 28 | this.button.addEventListener("keydown", (e) => { 29 | if ( 30 | ["ArrowDown", "Enter", " ", "ArrowDown"].includes(e.key) && 31 | !this.isOpen 32 | ) { 33 | e.preventDefault(); 34 | e.stopPropagation(); 35 | this.open({ focusFirst: true }); 36 | } 37 | if (e.key === "ArrowUp") { 38 | e.preventDefault(); 39 | e.stopPropagation(); 40 | this.open({ focusLast: true }); 41 | } 42 | }); 43 | 44 | // Add a11y attributes 45 | const contentId = 46 | this.content.getAttribute("id") ?? 47 | this.button.getAttribute("id") + "-content"; 48 | this.button.setAttribute("role", "button"); 49 | this.button.setAttribute("aria-expanded", "false"); 50 | this.button.setAttribute("aria-controls", contentId); 51 | this.button.setAttribute("aria-haspopup", "true"); 52 | this.content.setAttribute("id", contentId); 53 | this.content.setAttribute("role", "menu"); 54 | this.content.setAttribute( 55 | "aria-labelledby", 56 | this.button.getAttribute("id") 57 | ); 58 | } 59 | 60 | /** 61 | * @param {{focusFirst?: boolean, focusLast?: boolean}} options 62 | */ 63 | open(options = {}) { 64 | this.content.removeAttribute("hidden"); 65 | this.button.setAttribute("aria-expanded", "true"); 66 | if (options.focusFirst) { 67 | this.content.focusFirstElement(); 68 | } else if (options.focusLast) { 69 | this.content.focusLastElement(); 70 | } 71 | } 72 | 73 | close() { 74 | this.content.setAttribute("hidden", "hidden"); 75 | this.button.setAttribute("aria-expanded", "false"); 76 | } 77 | 78 | /** 79 | * @return {boolean} 80 | */ 81 | get isOpen() { 82 | return !this.content.hasAttribute("hidden"); 83 | } 84 | } 85 | 86 | /** 87 | * Custom element for the content of the dropdown menu 88 | * 89 | * @property {HTMLElement[]} focusableElements 90 | * @property {DropdownMenu} parentElement 91 | */ 92 | export class DropdownMenuContent extends HTMLElement { 93 | static get observedAttributes() { 94 | return ["hidden"]; 95 | } 96 | 97 | connectedCallback() { 98 | this.querySelectorAll("ul, li").forEach((element) => 99 | element.setAttribute("role", "none") 100 | ); 101 | } 102 | 103 | attributeChangedCallback(name, oldValue, newValue) { 104 | if (name === "hidden") { 105 | this.visibilityChangeCallback(newValue === null); 106 | } 107 | } 108 | 109 | /** 110 | * @param {boolean} isVisible 111 | */ 112 | visibilityChangeCallback(isVisible) { 113 | if (isVisible) { 114 | document.addEventListener("keydown", this.onKeyDown); 115 | document.addEventListener("keyup", this.onKeyUp); 116 | document.body.addEventListener("click", this.clickOutsideListener); 117 | this.focusableElements = focusableElements(this); 118 | this.focusableElements.forEach((e) => e.setAttribute("role", "menuitem")); 119 | } else { 120 | document.removeEventListener("keydown", this.onKeyDown); 121 | document.removeEventListener("keyup", this.onKeyUp); 122 | document.body.removeEventListener("click", this.clickOutsideListener); 123 | } 124 | } 125 | 126 | /** 127 | * @param {KeyboardEvent} e 128 | * @return {void} 129 | */ 130 | onKeyDown = (e) => { 131 | if (e.key === "Escape") { 132 | this.parentElement.close(); 133 | this.parentElement.button.focus(); 134 | return; 135 | } 136 | // If no focusable elements do not handle keyboard navigation 137 | if (this.focusableElements.length === 0) { 138 | return; 139 | } 140 | switch (e.key) { 141 | case "ArrowDown": 142 | e.preventDefault(); 143 | this.moveFocus(1); 144 | return; 145 | case "ArrowUp": 146 | e.preventDefault(); 147 | this.moveFocus(-1); 148 | return; 149 | case "Home": 150 | e.preventDefault(); 151 | this.focusableElements[0].focus(); 152 | return; 153 | case "End": 154 | e.preventDefault(); 155 | this.focusableElements[this.focusableElements.length - 1].focus(); 156 | return; 157 | default: 158 | // Do not trigger on keyboard shortcuts 159 | if (e.metaKey || e.ctrlKey || e.altKey) { 160 | return; 161 | } 162 | // Focus the element starting with the input letter 163 | const matchingElement = this.focusableElements.find( 164 | (element) => element.innerText[0].toLowerCase() === e.key 165 | ); 166 | if (matchingElement) { 167 | e.preventDefault(); 168 | e.stopPropagation(); 169 | matchingElement.focus(); 170 | } 171 | } 172 | }; 173 | 174 | /** 175 | * Focus a specific element 176 | */ 177 | focusFirstElement = (n) => { 178 | this.focusableElements[0].focus(); 179 | }; 180 | 181 | /** 182 | * Focus the last element 183 | */ 184 | focusLastElement = () => { 185 | this.focusableElements[this.focusableElements.length - 1].focus(); 186 | }; 187 | 188 | /** 189 | * Move the focus up or down 190 | * @param {number} n 191 | */ 192 | moveFocus = (n) => { 193 | const currentIndex = this.focusableElements.findIndex( 194 | (v) => v === document.activeElement 195 | ); 196 | const focusIndex = 197 | (currentIndex + n + this.focusableElements.length) % 198 | this.focusableElements.length; 199 | this.focusableElements[focusIndex].focus(); 200 | }; 201 | 202 | onKeyUp = (e) => { 203 | if (e.key === "Tab" && !this.contains(document.activeElement)) { 204 | this.parentElement.close(); 205 | } 206 | }; 207 | 208 | clickOutsideListener = (e) => { 209 | if (!this.contains(e.target)) { 210 | e.stopPropagation(); 211 | this.parentElement.close(); 212 | } 213 | }; 214 | } 215 | 216 | if (window.autoDefineComponent !== undefined) { 217 | customElements.define("dropdown-menu", DropdownMenu); 218 | customElements.define("dropdown-menu-content", DropdownMenuContent); 219 | } 220 | -------------------------------------------------------------------------------- /src/dropdown-menu/index.test.js: -------------------------------------------------------------------------------- 1 | require("expect-puppeteer"); 2 | 3 | /** 4 | * Test the behaviour of the dropdown menu component following the W3 specs 5 | * https://www.w3.org/TR/wai-aria-practices/examples/menu-button/menu-button-links.html 6 | * 7 | * We do not respect the `tabindex="-1"` since we want links to be browsable using tabs 8 | */ 9 | describe("DropdownMenu", () => { 10 | beforeEach(async () => { 11 | await page.goto( 12 | `http://localhost:6006/iframe.html?id=dropdownmenu--default&viewMode=story` 13 | ); 14 | // Focus the button for the start 15 | await page.keyboard.press("Tab"); 16 | }); 17 | 18 | describe("#Keyboard Navigation", () => { 19 | ["Enter", "Space", "ArrowDown"].forEach((key) => { 20 | it(`should focus on ${key} on arrow navigation`, async () => { 21 | await page.keyboard.press(key); 22 | await expect('[role="menu"]').toBeVisible(); 23 | await expect("#first-link").toBeFocused(); 24 | }); 25 | }); 26 | 27 | it(`should focus on last Element on arrow down`, async () => { 28 | await page.keyboard.press("ArrowUp"); 29 | await expect('[role="menu"]').toBeVisible(); 30 | await expect("#last-link").toBeFocused(); 31 | }); 32 | 33 | it(`should focus back the button when menu is closed`, async () => { 34 | await page.keyboard.press("ArrowUp"); 35 | await expect('[role="menu"]').toBeVisible(); 36 | await page.keyboard.press("Escape"); 37 | await expect('[role="menu"]').toBeHidden(); 38 | await expect('[role="button"]').toBeFocused(); 39 | }); 40 | 41 | it("should navigate between links on arrow down", async () => { 42 | await page.keyboard.press("Enter"); 43 | await expect('[role="menu"]').toBeVisible(); 44 | await page.keyboard.press("ArrowDown"); 45 | await expect("#second-link").toBeFocused(); 46 | await page.keyboard.press("ArrowDown"); 47 | await expect("#last-link").toBeFocused(); 48 | await page.keyboard.press("ArrowDown"); 49 | await expect("#first-link").toBeFocused(); 50 | }); 51 | 52 | it("should navigate between links on arrow up", async () => { 53 | await page.keyboard.press("Enter"); 54 | await expect('[role="menu"]').toBeVisible(); 55 | await page.keyboard.press("ArrowUp"); 56 | await expect("#last-link").toBeFocused(); 57 | await page.keyboard.press("ArrowUp"); 58 | await expect("#second-link").toBeFocused(); 59 | await page.keyboard.press("ArrowUp"); 60 | await expect("#first-link").toBeFocused(); 61 | }); 62 | 63 | it(`should focus the first links on "Home" press`, async () => { 64 | await page.keyboard.press("ArrowUp"); 65 | await expect('[role="menu"]').toBeVisible(); 66 | await page.keyboard.press("Home"); 67 | await expect("#first-link").toBeFocused(); 68 | }); 69 | 70 | it(`should focus the first links on "End" press`, async () => { 71 | await page.keyboard.press("Enter"); 72 | await expect('[role="menu"]').toBeVisible(); 73 | await page.keyboard.press("End"); 74 | await expect("#last-link").toBeFocused(); 75 | }); 76 | 77 | it(`should focus the right element on letter press`, async () => { 78 | await page.keyboard.press("Enter"); 79 | await expect('[role="menu"]').toBeVisible(); 80 | await page.keyboard.press("s"); 81 | await expect("#second-link").toBeFocused(); 82 | await page.keyboard.press("f"); 83 | await expect("#first-link").toBeFocused(); 84 | }); 85 | }); 86 | 87 | describe("#Mouse Navigation", () => { 88 | it("should open on click", async () => { 89 | await page.click('[role="button"]'); 90 | await expect('[role="menu"]').toBeVisible(); 91 | }); 92 | it("should open on click outside", async () => { 93 | await page.click('[role="button"]'); 94 | await expect('[role="menu"]').toBeVisible(); 95 | await page.click("body"); 96 | await expect('[role="menu"]').toBeHidden(); 97 | }); 98 | }); 99 | 100 | describe("#Aria attributes", () => { 101 | it("should put the right attributes on the button", async () => { 102 | await expect("[role=button]").toHaveAttribute("aria-haspopup", "true"); 103 | await expect("[role=button]").toHaveAttribute( 104 | "aria-controls", 105 | "menu-content" 106 | ); 107 | await expect("[role=button]").toHaveAttribute("aria-expanded", "false"); 108 | await page.keyboard.press("Enter"); 109 | await expect("[role=button]").toHaveAttribute("aria-expanded", "true"); 110 | }); 111 | 112 | it("should put the right attributes on the menu", async () => { 113 | await page.keyboard.press("Enter"); 114 | await expect("[role=menu]").toHaveAttribute("aria-labelledby", "menu"); 115 | await expect("[role=menu] li").toHaveAttribute("role", "none"); 116 | await expect("[role=menu] ul").toHaveAttribute("role", "none"); 117 | await expect("[role=menu] a").toHaveAttribute("role", "menuitem"); 118 | }); 119 | }); 120 | }); 121 | -------------------------------------------------------------------------------- /src/dropdown-menu/story.css: -------------------------------------------------------------------------------- 1 | dropdown-menu { 2 | position: relative; 3 | display: block; 4 | } 5 | dropdown-menu-content { 6 | position: absolute; 7 | top: 100%; 8 | left: 0; 9 | right: 0; 10 | background-color: #fff; 11 | padding: .5rem; 12 | width: 200px; 13 | box-shadow: rgb(255, 255, 255) 0px 0px 0px 0px, rgba(0, 0, 0, 0.05) 0px 0px 0px 1px, rgba(0, 0, 0, 0.1) 0px 10px 15px -3px, rgba(0, 0, 0, 0.1) 0px 4px 6px -4px; 14 | } 15 | dropdown-menu-content *:focus { 16 | color: red; 17 | } 18 | -------------------------------------------------------------------------------- /src/global.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --accent: #007bff; 3 | } 4 | 5 | body { 6 | font-family: sans-serif; 7 | max-width: 700px; 8 | margin-left: auto; 9 | margin-right: auto; 10 | color: #212529; 11 | background-color: #f7f7f7; 12 | } 13 | 14 | a { 15 | color: var(--accent); 16 | } 17 | 18 | h2 { 19 | font-size: 1.25rem; 20 | font-weight: 300; 21 | } 22 | 23 | hr { 24 | margin: 50px 0; 25 | border: none; 26 | border-bottom: 1px solid #00000020; 27 | } 28 | 29 | .card { 30 | background-color: #fff; 31 | padding: 16px; 32 | border: 1px solid #00000020; 33 | margin-bottom: 50px; 34 | } 35 | 36 | .card header { 37 | background-color: #00000008; 38 | border-bottom: 1px solid #00000020; 39 | margin: -16px -16px 16px; 40 | padding: 12px; 41 | font-weight: bold; 42 | } 43 | 44 | .stack { 45 | display: grid; 46 | grid-template-columns: 1fr; 47 | grid-gap: 16px; 48 | } 49 | 50 | pre { 51 | padding: 10px; 52 | background-color: #00000008; 53 | border: solid 1px #00000020; 54 | } 55 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | 11 | 12 |

Components

13 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export { default as NavTabs } from "./nav-tabs/index.js"; 2 | export { default as ScrollTop } from "./scroll-top/index.js"; 3 | export { default as ModalDialog } from "./modal-dialog/index.js"; 4 | export { default as CookieBanner } from "./cookie-banner/index.js"; 5 | export { default as TextareaAutogrow } from "./textarea-autogrow/index.js"; 6 | export { DropdownMenu, DropdownMenuContent } from "./dropdown-menu/index"; 7 | -------------------------------------------------------------------------------- /src/modal-dialog/ModalDialog.stories.mdx: -------------------------------------------------------------------------------- 1 | import { Meta, Story, Canvas, ArgsTable } from '@storybook/addon-docs/blocks'; 2 | import ModalDialog from './index'; 3 | import './story.css' 4 | 5 | export const Template = ({ label, overlayClose = true }) => { 6 | try { 7 | customElements.define('modal-dialog', ModalDialog) 8 | } catch (e) { 9 | // do nothing this is custom elements 10 | } 11 | return ` 12 |
13 | 14 | 15 |
16 | 22 | 55 | ` 56 | }; 57 | 58 | 61 | 62 | # modal-dialog 63 | 64 | ### Usage 65 | 66 | ```html 67 | 73 | ``` 74 | 75 | Every element with `data-dismiss` will automatically close the modal 76 | 77 | ### Attributes 78 | 79 | | Attribute | Type | Description | 80 | |-----------------|----------|------------------------------------------------------| 81 | | overlay-close | boolean | Hide the modal when you click on the overlay | 82 | | hidden | boolean | Visibility 83 | 84 | ### Demo 85 | 86 | 87 | 88 | {Template.bind({})} 89 | 90 | 91 | -------------------------------------------------------------------------------- /src/modal-dialog/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @property {Element|null} previouslyFocusedElement Element focused before the opening of the modal 3 | * @property {array} trapElements 4 | */ 5 | export default class ModalDialog extends HTMLElement { 6 | static get observedAttributes () { 7 | return ['hidden'] 8 | } 9 | 10 | constructor () { 11 | super() 12 | this.close = this.close.bind(this) 13 | this.onKeyDown = this.onKeyDown.bind(this) 14 | this.previouslyFocusedElement = null 15 | this.trapElements = [] 16 | } 17 | 18 | connectedCallback () { 19 | this.setAttribute('aria-modal', 'true') 20 | this.setAttribute('role', 'dialog') 21 | this.addEventListener('click', e => { 22 | if ( 23 | (e.target === this && this.getAttribute('overlay-close') !== null) || 24 | e.target.dataset.dismiss !== undefined || 25 | e.target.closest('[data-dismiss]') !== null 26 | ) { 27 | this.close() 28 | } 29 | }) 30 | this.createTrapFocusElement('afterbegin') 31 | this.createTrapFocusElement('beforeend') 32 | document.addEventListener('keydown', this.onKeyDown) 33 | } 34 | 35 | disconnectedCallback () { 36 | document.removeEventListener('keydown', this.onKeyDown) 37 | this.trapElements.forEach(element => 38 | element.parentElement.removeChild(element) 39 | ) 40 | this.trapElements = [] 41 | } 42 | 43 | attributeChangedCallback (name, oldValue, newValue) { 44 | if (name === 'hidden' && newValue === null) { 45 | this.previouslyFocusedElement = document.activeElement 46 | const firstInput = this.getFocusableElements()[0] 47 | if (firstInput) { 48 | firstInput.focus() 49 | } 50 | document.addEventListener('keydown', this.onKeyDown) 51 | this.removeAttribute('aria-hidden') 52 | } 53 | if (name === 'hidden' && newValue === 'hidden') { 54 | if (this.previouslyFocusedElement !== null) { 55 | this.previouslyFocusedElement.focus() 56 | } 57 | this.previouslyFocusedElement = null 58 | this.setAttribute('aria-hidden', 'true') 59 | document.removeEventListener('keydown', this.onKeyDown) 60 | } 61 | } 62 | 63 | /** 64 | * @param {KeyboardEvent} e 65 | */ 66 | onKeyDown (e) { 67 | if (e.key === 'Escape') { 68 | this.close() 69 | } 70 | } 71 | 72 | close () { 73 | const event = new CustomEvent('close', { 74 | detail: { close: true }, 75 | cancelable: true 76 | }) 77 | this.dispatchEvent(event) 78 | if (!event.defaultPrevented) { 79 | this.setAttribute('hidden', 'hidden') 80 | } 81 | } 82 | 83 | /** 84 | * Create an element used to trap focus inside the dialog 85 | * 86 | * @param position 87 | */ 88 | createTrapFocusElement (position) { 89 | const element = document.createElement('div') 90 | element.setAttribute('tabindex', '0') 91 | element.addEventListener('focus', () => { 92 | const focusableElements = this.getFocusableElements() 93 | if (focusableElements.length > 0) { 94 | focusableElements[ 95 | position === 'afterbegin' ? focusableElements.length - 1 : 0 96 | ].focus() 97 | } 98 | }) 99 | this.trapElements.push(element) 100 | this.insertAdjacentElement(position, element) 101 | } 102 | 103 | /** 104 | * @return array 105 | */ 106 | getFocusableElements () { 107 | const selector = `[href], 108 | button:not([disabled]), 109 | input:not([disabled]), 110 | select:not([disabled]), 111 | textarea:not([disabled]), 112 | [tabindex]:not([tabindex="-1"]` 113 | return Array.from(this.querySelectorAll(selector)).filter(element => { 114 | const rect = element.getBoundingClientRect() 115 | return rect.width > 0 && rect.height > 0 116 | }) 117 | } 118 | } 119 | 120 | if (window.autoDefineComponent !== undefined) { 121 | customElements.define('modal-dialog', ModalDialog) 122 | } 123 | -------------------------------------------------------------------------------- /src/modal-dialog/index.test.js: -------------------------------------------------------------------------------- 1 | require('expect-puppeteer') 2 | const { wait, getListenersFor } = require('../utils/jest') 3 | 4 | describe('#modal-dialog', () => { 5 | beforeEach(async () => { 6 | await page.goto( 7 | `http://localhost:6006/iframe.html?id=modaldialog--default&viewMode=story` 8 | ) 9 | }) 10 | 11 | it('should not be visible by default', async () => { 12 | const modalDialog = await page.$('modal-dialog') 13 | await expect(modalDialog).toBeHidden() 14 | }) 15 | 16 | describe('from dialog opened', () => { 17 | beforeEach(async () => { 18 | const modalDialog = await page.$('modal-dialog') 19 | const button = await page.$('#button') 20 | await button.click() 21 | await expect(modalDialog).toBeVisible() 22 | }) 23 | 24 | it('should focus the first input when opining dialog', async () => { 25 | const focusedElement = await page.evaluate(_ => document.activeElement.id) 26 | expect(focusedElement).toBe('firstname') 27 | }) 28 | 29 | it('should close the dialog on Escape', async () => { 30 | await page.keyboard.press('Escape') 31 | const modalDialog = await page.$('modal-dialog') 32 | await expect(modalDialog).toBeHidden() 33 | }) 34 | 35 | it('should focus the previously focused element', async () => { 36 | await page.keyboard.press('Escape') 37 | const focusedElement = await page.evaluate(_ => document.activeElement.id) 38 | expect(focusedElement).toBe('button') 39 | }) 40 | 41 | it('should hide modal on overlay click', async () => { 42 | const modalDialog = await page.$('modal-dialog') 43 | await wait(200) 44 | await page.mouse.click(5, 5) 45 | await wait(200) 46 | await expect(modalDialog).toBeHidden() 47 | }) 48 | 49 | it("shouldn't hide modal on content click", async () => { 50 | const modalDialog = await page.$('modal-dialog') 51 | const modalBox = await page.$('#modal-box') 52 | await modalBox.click() 53 | await expect(modalDialog).toBeVisible() 54 | }) 55 | 56 | it('should remove event when hidden', async () => { 57 | const baseLength = (await getListenersFor(page, 'document')).length 58 | await wait(200) 59 | await page.mouse.click(5, 5) 60 | expect(await getListenersFor(page, 'document')).toHaveLength( 61 | baseLength - 1 62 | ) 63 | }) 64 | 65 | it('should remove event when hidden', async () => { 66 | const baseLength = (await getListenersFor(page, 'document')).length 67 | await page.evaluate(_ => (document.body.innerHTML = '')) 68 | expect(await getListenersFor(page, 'document')).toHaveLength( 69 | baseLength - 1 70 | ) 71 | }) 72 | 73 | it('should focus the last element on previous focus', async () => { 74 | await page.keyboard.down('Shift') 75 | await page.keyboard.press('Tab') 76 | await page.keyboard.up('Shift') 77 | const focusedElement = await page.evaluate(_ => document.activeElement.id) 78 | expect(focusedElement).toBe('closebutton') 79 | }) 80 | 81 | it('should focus the first element on next focus', async () => { 82 | await page.keyboard.press('Tab') 83 | await page.keyboard.press('Tab') 84 | await page.keyboard.press('Tab') 85 | await page.keyboard.press('Tab') 86 | await page.keyboard.press('Tab') 87 | const focusedElement = await page.evaluate(_ => document.activeElement.id) 88 | expect(focusedElement).toBe('firstname') 89 | }) 90 | 91 | it('should close the modal when clicking on the close button', async () => { 92 | const closeButton = await page.$('[data-dismiss]') 93 | const modalDialog = await page.$('modal-dialog') 94 | await closeButton.click() 95 | await expect(modalDialog).toBeHidden() 96 | }) 97 | 98 | it('should not hide the modal if event prevented', async () => { 99 | const closeButton = await page.$('[data-dismiss]') 100 | const modalDialog = await page.$('modal-dialog') 101 | await modalDialog.evaluate(modal => { 102 | modal.addEventListener('close', e => e.preventDefault()) 103 | }) 104 | await closeButton.click() 105 | await expect(modalDialog).not.toBeHidden() 106 | }) 107 | }) 108 | }) 109 | -------------------------------------------------------------------------------- /src/modal-dialog/story.css: -------------------------------------------------------------------------------- 1 | modal-dialog { 2 | position: fixed; 3 | top: 0; 4 | left: 0; 5 | right: 0; 6 | bottom: 0; 7 | background-color: rgba(0, 0, 0, 0.4); 8 | display: flex; 9 | align-items: center; 10 | justify-content: center; 11 | z-index: 1000; 12 | 13 | opacity: 1; 14 | transition: 0.3s; 15 | } 16 | modal-dialog[hidden] { 17 | opacity: 0; 18 | transform: scale(1.05); 19 | pointer-events: none; 20 | } 21 | .modal-close { 22 | background-color: transparent; 23 | position: absolute; 24 | top: 12px; 25 | right: 10px; 26 | border: none; 27 | cursor: pointer; 28 | } 29 | modal-dialog .card { 30 | position: relative; 31 | max-width: 500px; 32 | width: calc(100vw - 40px); 33 | animation: 0.4s slideIn both; 34 | } 35 | -------------------------------------------------------------------------------- /src/nav-tabs/NavTabs.stories.mdx: -------------------------------------------------------------------------------- 1 | import { Meta, Story, Canvas } from '@storybook/addon-docs/blocks'; 2 | import NavTabs from './index'; 3 | import './story.css' 4 | 5 | 8 | 9 | # nav-tabs 10 | 11 | The goal of this component is to provide an unopinionated component to 12 | create tabs. 13 | 14 | ### With links 15 | 16 | You can use regular links (with anchors) to control what element to display 17 | 18 | ```html 19 | 20 | Tab 1 21 | Tab 2 22 | Tab 3 23 | 24 | ``` 25 | 26 | export const Template = ({ label, button = false }) => { 27 | const suffix = button ? 'button' : 'link'; 28 | try { 29 | customElements.define('nav-tabs', NavTabs) 30 | } catch (e) { 31 | // do nothing this is custom elements 32 | } 33 | return ` 34 | 35 | ${button ? 36 | ` 37 | 38 | 39 | 40 | ` : 41 | ` 42 | Tab 1 43 | Tab 2 44 | Tab 3 45 | ` 46 | } 47 | 48 | 56 | 64 |
65 |

66 | Aliquid ea doloribus eum exercitationem sunt! Reiciendis modi saepe 67 | nostrum ipsa laborum, laudantium natus consequuntur sint, ratione 68 | nesciunt tenetur quo obcaecati velit porro! Repudiandae vel cum unde 69 | quia rem similique. 70 |

71 |

I'm tab 3 content

72 |
73 | ` 74 | }; 75 | 76 | 77 | 78 | {Template.bind({})} 79 | 80 | 81 | 82 | ### With buttons 83 | 84 | You can also use buttons (if you don't want anchors navigation). 85 | 86 | ```html 87 | 88 | 89 | 90 | 91 | 92 | ``` 93 | 94 | 95 | 96 | {Template.bind({})} 97 | 98 | 99 | -------------------------------------------------------------------------------- /src/nav-tabs/index.js: -------------------------------------------------------------------------------- 1 | export default class Tabs extends HTMLElement { 2 | constructor () { 3 | super() 4 | this.onHashChange = this.onHashChange.bind(this) 5 | } 6 | 7 | connectedCallback () { 8 | this.setAttribute('role', 'tablist') 9 | const tabs = Array.from(this.children) 10 | const hash = window.location.hash.replace('#', '') 11 | let currentTab = tabs[0] 12 | 13 | tabs.forEach((tab, i) => { 14 | const id = 15 | tab.tagName === 'A' 16 | ? tab.getAttribute('href').replace('#', '') 17 | : tab.getAttribute('aria-controls') 18 | const tabpanel = document.getElementById(id) 19 | 20 | // Should the element be the current element ? 21 | if (tab.getAttribute('aria-selected') === 'true' && hash === '') { 22 | currentTab = tab 23 | } 24 | if (id === hash) { 25 | currentTab = tab 26 | } 27 | 28 | // Extra attributes to improve accessibility 29 | tab.setAttribute('role', 'tab') 30 | tab.setAttribute('aria-selected', 'false') 31 | tab.setAttribute('tabindex', '-1') 32 | tab.setAttribute('aria-controls', id) 33 | tab.getAttribute('id') || tab.setAttribute('id', 'tab-' + id) 34 | tabpanel.setAttribute('role', 'tabpanel') 35 | tabpanel.setAttribute('aria-labelledby', tab.getAttribute('id')) 36 | tabpanel.setAttribute('hidden', 'hidden') 37 | tabpanel.setAttribute('tabindex', '0') 38 | 39 | // Keyboard navigation (for accessibility purpose) 40 | tab.addEventListener('keyup', e => { 41 | let index = null 42 | if (e.key === 'ArrowRight') { 43 | index = i === tabs.length - 1 ? 0 : i + 1 44 | } else if (e.key === 'ArrowLeft') { 45 | index = i === 0 ? tabs.length - 1 : i - 1 46 | } else if (e.key === 'Home') { 47 | index = 0 48 | } else if (e.key === 'End') { 49 | index = tabs.length - 1 50 | } 51 | if (index !== null) { 52 | this.activate(tabs[index]) 53 | tabs[index].focus() 54 | } 55 | }) 56 | // Mouse control 57 | tab.addEventListener('click', e => { 58 | e.preventDefault() 59 | this.activate(tab, tab.tagName === 'A') 60 | }) 61 | }) 62 | 63 | window.addEventListener('hashchange', this.onHashChange) 64 | 65 | this.activate(currentTab, false) 66 | if (currentTab.getAttribute('aria-controls') === hash) { 67 | window.requestAnimationFrame(() => { 68 | currentTab.scrollIntoView({ 69 | behavior: 'smooth' 70 | }) 71 | }) 72 | } 73 | } 74 | 75 | disconnectedCallback () { 76 | window.removeEventListener('hashchange', this.onHashChange) 77 | } 78 | 79 | /** 80 | * Detects hashChange and activate the current tab if necessary 81 | */ 82 | onHashChange () { 83 | const tab = Array.from(this.children).find( 84 | tab => tab.getAttribute('href') === window.location.hash 85 | ) 86 | if (tab !== undefined) { 87 | this.activate(tab) 88 | document.querySelector(window.location.hash).scrollIntoView({ 89 | behavior: 'smooth' 90 | }) 91 | } 92 | } 93 | 94 | /** 95 | * @param {HTMLElement} tab 96 | * @param {boolean} changeHash 97 | */ 98 | activate (tab, changeHash = true) { 99 | const currentTab = this.querySelector('[aria-selected="true"]') 100 | if (currentTab !== null) { 101 | const tabpanel = document.getElementById( 102 | currentTab.getAttribute('aria-controls') 103 | ) 104 | currentTab.setAttribute('aria-selected', 'false') 105 | currentTab.setAttribute('tabindex', '-1') 106 | tabpanel.setAttribute('hidden', 'hidden') 107 | } 108 | const id = tab.getAttribute('aria-controls') 109 | const tabpanel = document.getElementById(id) 110 | tab.setAttribute('aria-selected', 'true') 111 | tab.setAttribute('tabindex', '0') 112 | tabpanel.removeAttribute('hidden') 113 | if (changeHash) { 114 | window.history.replaceState({}, '', '#' + id) 115 | } 116 | } 117 | } 118 | if (window.autoDefineComponent !== undefined) { 119 | customElements.define('nav-tabs', Tabs) 120 | } 121 | -------------------------------------------------------------------------------- /src/nav-tabs/index.test.js: -------------------------------------------------------------------------------- 1 | require('expect-puppeteer') 2 | 3 | describe('Tabs custom element', () => { 4 | describe('#Tabs with links', () => { 5 | beforeEach(async () => { 6 | await page.goto( 7 | `http://localhost:6006/iframe.html?id=navtabs--default&viewMode=story` 8 | ) 9 | }) 10 | 11 | describe('#Arrow Navigation', () => { 12 | it('should handle leftArrow navigation', async () => { 13 | await page.keyboard.press('Tab') 14 | await page.keyboard.press('ArrowLeft') 15 | const selectedTab = await page.$('[aria-selected="true"]') 16 | await expect(selectedTab).toMatch('Tab 2') 17 | }) 18 | 19 | it('should handle rightArrow navigation', async () => { 20 | await page.keyboard.press('Tab') 21 | await page.keyboard.press('ArrowRight') 22 | const selectedTab = await page.$('[aria-selected="true"]') 23 | await expect(selectedTab).toMatch('Tab 1') 24 | }) 25 | }) 26 | 27 | describe('#Hash Navigation', () => { 28 | it('should handle hash change navigation', async () => { 29 | await page.goto( 30 | `http://localhost:6006/iframe.html?id=navtabs--default&viewMode=story#tab2link` 31 | ) 32 | const selectedTab = await page.$('[aria-selected="true"]') 33 | await expect(selectedTab).toMatch('Tab 2') 34 | }) 35 | }) 36 | 37 | describe('#Tab index', () => { 38 | it('should focus tab item first', async () => { 39 | await page.keyboard.press('Tab') 40 | const focusedElement = await page.$('*:focus') 41 | await expect(focusedElement).toMatch('Tab 3') 42 | }) 43 | 44 | it('should focus tabpanel item first', async () => { 45 | await page.keyboard.press('Tab') 46 | await page.keyboard.press('Tab') 47 | const focusedElement = await page.$('*:focus') 48 | await expect(focusedElement).toMatch("I'm tab 3 content") 49 | }) 50 | }) 51 | }) 52 | 53 | describe('#Tabs with button', () => { 54 | beforeEach(async () => { 55 | await page.goto( 56 | `http://localhost:6006/iframe.html?id=navtabs--with-buttons&viewMode=story` 57 | ) 58 | }) 59 | it('should select the first button', async () => { 60 | const selectedElement = await page.$('[aria-selected="true"]') 61 | await expect(selectedElement).toMatch('Tab 1') 62 | }) 63 | 64 | it('should handle click correctly', async () => { 65 | const tabContent1 = await page.$('#tab1button') 66 | const tabContent2 = await page.$('#tab2button') 67 | const tab2 = await page.$('#btntab2') 68 | const hiddenAttribute = c => c.getAttribute('hidden') 69 | expect(await tabContent2.evaluate(hiddenAttribute)).toBe('hidden') 70 | expect(await tabContent1.evaluate(hiddenAttribute)).toBeNull() 71 | await tab2.click() 72 | expect(await tabContent1.evaluate(hiddenAttribute)).toBe('hidden') 73 | expect(await tabContent2.evaluate(hiddenAttribute)).toBeNull() 74 | }) 75 | 76 | describe('#Optimisation', () => { 77 | it('should clean listeners on remove', async () => { 78 | await page.$eval('body', body => (body.innerHTML = '')) 79 | const client = await page.target().createCDPSession() 80 | const window = await client.send('Runtime.evaluate', { 81 | expression: 'window' 82 | }) 83 | const listeners = ( 84 | await client.send('DOMDebugger.getEventListeners', { 85 | objectId: window.result.objectId 86 | }) 87 | ).listeners 88 | expect(listeners.filter(l => l.type === 'hashchange')).toHaveLength(0) 89 | }) 90 | }) 91 | }) 92 | }) 93 | -------------------------------------------------------------------------------- /src/nav-tabs/story.css: -------------------------------------------------------------------------------- 1 | .nav-pills { 2 | display: flex; 3 | justify-content: center; 4 | } 5 | 6 | .nav-pills > * { 7 | padding: 8px 16px; 8 | text-decoration: none; 9 | border-radius: 3px; 10 | border: none; 11 | background: transparent; 12 | color: var(--accent); 13 | font-size: 1rem; 14 | } 15 | 16 | .nav-pills > *[aria-selected='true'] { 17 | background: var(--accent); 18 | color: #fff; 19 | } 20 | 21 | .nav-pills > *:hover, 22 | .nav-pills > *:focus { 23 | background: var(--accent) 24 | linear-gradient(to top, rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5)); 25 | color: #fff; 26 | } 27 | -------------------------------------------------------------------------------- /src/scroll-top/ScrollTop.stories.mdx: -------------------------------------------------------------------------------- 1 | import { Meta, Story, Canvas } from '@storybook/addon-docs/blocks'; 2 | import ScrollTop from './index'; 3 | import './story.css' 4 | 5 | 6 | 7 | # scroll-top 8 | 9 | The goal of this component is to provide an unopinionated component to 10 | scroll back to the top of the page. 11 | 12 | ```html 13 | 16 | ``` 17 | 18 | export const Template = ({ label, ...args }) => { 19 | try { 20 | customElements.define('scroll-top', ScrollTop) 21 | } catch (e) { 22 | // do nothing this is custom elements 23 | } 24 | return ` 25 |
Scroll Me
26 | 27 | ` 28 | }; 29 | 30 | 31 | 32 | {Template.bind({})} 33 | 34 | 35 | -------------------------------------------------------------------------------- /src/scroll-top/ScrollTop.test.js: -------------------------------------------------------------------------------- 1 | require('expect-puppeteer') 2 | 3 | async function nextAnimationFrame (page) { 4 | return page.evaluate( 5 | () => 6 | new Promise((resolve, reject) => { 7 | window.requestAnimationFrame(resolve) 8 | }) 9 | ) 10 | } 11 | 12 | describe('#scroll-top', () => { 13 | beforeEach(async () => { 14 | await page.goto( 15 | `http://localhost:6006/iframe.html?id=scrolltop--default&viewMode=story` 16 | ) 17 | await page.keyboard.press('Tab') 18 | }) 19 | 20 | it('should not be visible by default', async () => { 21 | const box = await (await page.$('scroll-top')).boundingBox() 22 | expect(box).toBeNull() 23 | }) 24 | 25 | it('should be visible when scrolling', async () => { 26 | await page.evaluate(async el => window.scrollBy(0, window.innerHeight)) 27 | await nextAnimationFrame(page) 28 | const box = await (await page.$('scroll-top')).boundingBox() 29 | expect(box).not.toBeNull() 30 | }) 31 | 32 | it('should scroll top on click', async () => { 33 | await page.evaluate(async el => window.scrollBy(0, window.innerHeight)) 34 | await page.waitForSelector('scroll-top', { visible: true }) 35 | const previousScrollY = await page.evaluate(_ => window.scrollY) 36 | const scrollTop = await page.$('scroll-top') 37 | await scrollTop.click() 38 | await page.waitForTimeout(100) 39 | const scrollY = await page.evaluate(_ => window.scrollY) 40 | expect(scrollY).not.toBe(previousScrollY) 41 | }) 42 | 43 | it('should clean listeners on remove', async () => { 44 | await page.$eval('body', body => (body.innerHTML = '')) 45 | const client = await page.target().createCDPSession() 46 | const window = await client.send('Runtime.evaluate', { 47 | expression: 'window' 48 | }) 49 | const listeners = ( 50 | await client.send('DOMDebugger.getEventListeners', { 51 | objectId: window.result.objectId 52 | }) 53 | ).listeners 54 | expect(listeners.filter(l => l.type === 'scroll')).toHaveLength(0) 55 | }) 56 | }) 57 | -------------------------------------------------------------------------------- /src/scroll-top/index.js: -------------------------------------------------------------------------------- 1 | import { throttle } from '../utils/time.js' 2 | 3 | /** 4 | * This is some informations 5 | */ 6 | export default class ScrollTop extends HTMLElement { 7 | constructor () { 8 | super() 9 | this.onScroll = throttle(this.onScroll.bind(this), 100) 10 | this.isVisible = false 11 | } 12 | 13 | connectedCallback () { 14 | this.addEventListener('click', () => { 15 | window.scrollTo({ 16 | top: 0, 17 | behavior: 'smooth' 18 | }) 19 | }) 20 | window.addEventListener('scroll', this.onScroll) 21 | } 22 | 23 | disconnectedCallback () { 24 | window.removeEventListener('scroll', this.onScroll) 25 | } 26 | 27 | onScroll () { 28 | const threshold = window.innerHeight / 3 29 | if (window.scrollY > threshold && this.isVisible === false) { 30 | this.removeAttribute('hidden', 'hidden') 31 | this.isVisible = true 32 | } else if (window.scrollY < threshold && this.isVisible === true) { 33 | this.setAttribute('hidden', 'hidden') 34 | this.isVisible = false 35 | } 36 | } 37 | } 38 | if (window.autoDefineComponent !== undefined) { 39 | customElements.define('scroll-top', ScrollTop) 40 | } 41 | -------------------------------------------------------------------------------- /src/scroll-top/story.css: -------------------------------------------------------------------------------- 1 | .container { 2 | padding: 20px; 3 | width: 80%; 4 | height: 200vw; 5 | background-color: rgba(0, 0, 0, 0.05); 6 | margin: 50px auto; 7 | } 8 | 9 | .scrolltop { 10 | background-color: darkgray; 11 | width: 40px; 12 | height: 40px; 13 | display: flex; 14 | align-items: center; 15 | justify-content: center; 16 | border-radius: 4px; 17 | color: #fff; 18 | position: fixed; 19 | right: 25px; 20 | bottom: 25px; 21 | opacity: 1; 22 | transition: opacity 0.3s; 23 | transform: rotate(180deg); 24 | font-weight: bold; 25 | cursor: pointer; 26 | } 27 | .scrolltop:hover { 28 | background-color: gray; 29 | } 30 | .scrolltop[hidden] { 31 | display: none; 32 | } 33 | -------------------------------------------------------------------------------- /src/textarea-autogrow/TextareaAutogrow.stories.mdx: -------------------------------------------------------------------------------- 1 | import { Meta, Story, Canvas } from '@storybook/addon-docs/blocks'; 2 | import TextareaAutogrow from './index'; 3 | import './story.css' 4 | 5 | 6 | 7 | # textarea-autogrow 8 | 9 | This textarea will autogrow if the content overflow the initial height (it's triggered on focus) 10 | 11 | ```html 12 | 13 | ``` 14 | 15 | export const Template = ({ label, ...args }) => { 16 | try { 17 | customElements.define('textarea-autogrow', TextareaAutogrow, { extends: 'textarea' }) 18 | } catch (e) { 19 | // do nothing this is custom elements 20 | } 21 | return `` 26 | }; 27 | 28 | 29 | 30 | {Template.bind({})} 31 | 32 | 33 | -------------------------------------------------------------------------------- /src/textarea-autogrow/index.js: -------------------------------------------------------------------------------- 1 | import { debounce } from '../utils/time.js' 2 | 3 | export default class Autogrow extends HTMLTextAreaElement { 4 | autogrow () { 5 | const previousHeight = this.style.height 6 | this.style.height = 'auto' 7 | if (this.style.height !== previousHeight) { 8 | this.dispatchEvent( 9 | new CustomEvent('grow', { 10 | detail: { 11 | height: this.scrollHeight 12 | } 13 | }) 14 | ) 15 | } 16 | this.style.height = this.scrollHeight + 'px' 17 | } 18 | 19 | onFocus () { 20 | this.autogrow() 21 | window.addEventListener('resize', this.onResize) 22 | this.removeEventListener('focus', this.onFocus) 23 | } 24 | 25 | onResize () { 26 | this.autogrow() 27 | } 28 | 29 | connectedCallback () { 30 | this.style.overflow = 'hidden' 31 | this.style.resize = 'none' 32 | this.addEventListener('input', this.autogrow) 33 | this.addEventListener('focus', this.onFocus) 34 | } 35 | 36 | disconnectedCallback () { 37 | window.removeEventListener('resize', this.onResize) 38 | } 39 | 40 | constructor () { 41 | super() 42 | this.autogrow = this.autogrow.bind(this) 43 | this.onResize = debounce(this.onResize.bind(this), 300) 44 | this.onFocus = this.onFocus.bind(this) 45 | } 46 | } 47 | 48 | if (window.autoDefineComponent !== undefined) { 49 | customElements.define('textarea-autogrow', Autogrow, { extends: 'textarea' }) 50 | } 51 | -------------------------------------------------------------------------------- /src/textarea-autogrow/index.test.js: -------------------------------------------------------------------------------- 1 | require('expect-puppeteer') 2 | 3 | beforeEach(async () => { 4 | await page.goto( 5 | `http://localhost:6006/iframe.html?id=textareaautogrow--default&viewMode=story` 6 | ) 7 | }) 8 | 9 | function textareaHeight () { 10 | return page.evaluate(() => document.querySelector('#content').offsetHeight) 11 | } 12 | 13 | describe('#textarea-autogrow', () => { 14 | it('should grow on focus', async () => { 15 | const textarea = await page.$('#content') 16 | const defaultHeight = await textareaHeight() 17 | await textarea.focus() 18 | await expect(await textareaHeight()).toBeGreaterThan(defaultHeight) 19 | }) 20 | 21 | it('should shrink when less text', async () => { 22 | const textarea = await page.$('#content') 23 | await textarea.focus() 24 | const height = await textareaHeight() 25 | await page.keyboard.down('Control') 26 | await page.keyboard.press('A') 27 | await page.keyboard.up('Control') 28 | await page.keyboard.up('Backspace') 29 | await page.keyboard.type('Hello world') 30 | await expect(await textareaHeight()).toBeLessThan(height) 31 | }) 32 | }) 33 | -------------------------------------------------------------------------------- /src/textarea-autogrow/story.css: -------------------------------------------------------------------------------- 1 | textarea { 2 | box-sizing: border-box; 3 | padding: 10px; 4 | min-height: 6em; 5 | width: 100%; 6 | } 7 | -------------------------------------------------------------------------------- /src/utils/dom.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Gets keyboard-focusable elements within a specified element 3 | * @param {HTMLElement} [element=document] element 4 | * @returns {Array} 5 | */ 6 | export function focusableElements (element) { 7 | return Array.from(element.querySelectorAll( 8 | 'a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), details:not([disabled]),[tabindex]:not([tabindex="-1"])' 9 | )) 10 | } 11 | -------------------------------------------------------------------------------- /src/utils/jest.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @param page 3 | * @param {string} expression 4 | * @param {string} eventName 5 | */ 6 | async function getListenersFor (page, expression, eventName = null) { 7 | const client = await page.target().createCDPSession() 8 | const element = await client.send('Runtime.evaluate', { expression }) 9 | const listeners = ( 10 | await client.send('DOMDebugger.getEventListeners', { 11 | objectId: element.result.objectId 12 | }) 13 | ).listeners 14 | if (eventName) { 15 | return listeners.filter(l => l.type === eventName) 16 | } 17 | return listeners 18 | } 19 | 20 | /** 21 | * Wait for nextAnimationFrame 22 | * 23 | * @param page 24 | * @return {Promise} 25 | */ 26 | function nextAnimationFrame (page) { 27 | return page.evaluate( 28 | () => 29 | new Promise((resolve, reject) => { 30 | window.requestAnimationFrame(resolve) 31 | }) 32 | ) 33 | } 34 | 35 | /** 36 | * Promise based timeout 37 | * 38 | * @param {number} time 39 | * @return {Promise} 40 | */ 41 | function wait (time) { 42 | return new Promise((resolve, reject) => { 43 | setTimeout(function () { 44 | resolve() 45 | }, time) 46 | }) 47 | } 48 | 49 | module.exports = { 50 | nextAnimationFrame, 51 | wait, 52 | getListenersFor 53 | } 54 | -------------------------------------------------------------------------------- /src/utils/time.js: -------------------------------------------------------------------------------- 1 | export function debounce (callback, delay) { 2 | let timer 3 | return function () { 4 | const args = arguments 5 | const context = this 6 | clearTimeout(timer) 7 | timer = setTimeout(function () { 8 | callback.apply(context, args) 9 | }, delay) 10 | } 11 | } 12 | 13 | export function throttle (callback, delay) { 14 | let last 15 | let timer 16 | return function () { 17 | const context = this 18 | const now = +new Date() 19 | const args = arguments 20 | if (last && now < last + delay) { 21 | clearTimeout(timer) 22 | timer = setTimeout(function () { 23 | last = now 24 | callback.apply(context, args) 25 | }, delay) 26 | } else { 27 | last = now 28 | callback.apply(context, args) 29 | } 30 | } 31 | } 32 | --------------------------------------------------------------------------------