diff --git a/.yarn/offline-mirror/@babel-core-7.5.5.tgz b/.yarn/offline-mirror/@babel-core-7.5.5.tgz
new file mode 100644
index 000000000000..273bde99e7c4
Binary files /dev/null and b/.yarn/offline-mirror/@babel-core-7.5.5.tgz differ
diff --git a/.yarn/offline-mirror/@babel-helper-define-map-7.5.5.tgz b/.yarn/offline-mirror/@babel-helper-define-map-7.5.5.tgz
new file mode 100644
index 000000000000..500309195d00
Binary files /dev/null and b/.yarn/offline-mirror/@babel-helper-define-map-7.5.5.tgz differ
diff --git a/.yarn/offline-mirror/@babel-helper-member-expression-to-functions-7.5.5.tgz b/.yarn/offline-mirror/@babel-helper-member-expression-to-functions-7.5.5.tgz
new file mode 100644
index 000000000000..7d2471ee0f98
Binary files /dev/null and b/.yarn/offline-mirror/@babel-helper-member-expression-to-functions-7.5.5.tgz differ
diff --git a/.yarn/offline-mirror/@babel-helper-replace-supers-7.5.5.tgz b/.yarn/offline-mirror/@babel-helper-replace-supers-7.5.5.tgz
new file mode 100644
index 000000000000..b34ac479030e
Binary files /dev/null and b/.yarn/offline-mirror/@babel-helper-replace-supers-7.5.5.tgz differ
diff --git a/.yarn/offline-mirror/@babel-plugin-transform-block-scoping-7.5.5.tgz b/.yarn/offline-mirror/@babel-plugin-transform-block-scoping-7.5.5.tgz
new file mode 100644
index 000000000000..1e4095c84db6
Binary files /dev/null and b/.yarn/offline-mirror/@babel-plugin-transform-block-scoping-7.5.5.tgz differ
diff --git a/.yarn/offline-mirror/@babel-plugin-transform-classes-7.5.5.tgz b/.yarn/offline-mirror/@babel-plugin-transform-classes-7.5.5.tgz
new file mode 100644
index 000000000000..addfe69d7eaa
Binary files /dev/null and b/.yarn/offline-mirror/@babel-plugin-transform-classes-7.5.5.tgz differ
diff --git a/.yarn/offline-mirror/@babel-plugin-transform-object-super-7.5.5.tgz b/.yarn/offline-mirror/@babel-plugin-transform-object-super-7.5.5.tgz
new file mode 100644
index 000000000000..0bcbc777cd54
Binary files /dev/null and b/.yarn/offline-mirror/@babel-plugin-transform-object-super-7.5.5.tgz differ
diff --git a/.yarn/offline-mirror/@babel-preset-env-7.5.5.tgz b/.yarn/offline-mirror/@babel-preset-env-7.5.5.tgz
new file mode 100644
index 000000000000..90d48986d26f
Binary files /dev/null and b/.yarn/offline-mirror/@babel-preset-env-7.5.5.tgz differ
diff --git a/.yarn/offline-mirror/@types-node-12.7.3.tgz b/.yarn/offline-mirror/@types-node-12.7.3.tgz
new file mode 100644
index 000000000000..ad1693949a62
Binary files /dev/null and b/.yarn/offline-mirror/@types-node-12.7.3.tgz differ
diff --git a/.yarn/offline-mirror/acorn-7.0.0.tgz b/.yarn/offline-mirror/acorn-7.0.0.tgz
new file mode 100644
index 000000000000..a2964fe07e5d
Binary files /dev/null and b/.yarn/offline-mirror/acorn-7.0.0.tgz differ
diff --git a/.yarn/offline-mirror/ajv-6.10.2.tgz b/.yarn/offline-mirror/ajv-6.10.2.tgz
new file mode 100644
index 000000000000..5d591842d429
Binary files /dev/null and b/.yarn/offline-mirror/ajv-6.10.2.tgz differ
diff --git a/.yarn/offline-mirror/ajv-keywords-3.4.1.tgz b/.yarn/offline-mirror/ajv-keywords-3.4.1.tgz
new file mode 100644
index 000000000000..a6d89890319a
Binary files /dev/null and b/.yarn/offline-mirror/ajv-keywords-3.4.1.tgz differ
diff --git a/.yarn/offline-mirror/autoprefixer-9.6.1.tgz b/.yarn/offline-mirror/autoprefixer-9.6.1.tgz
new file mode 100644
index 000000000000..d37d08e7e69e
Binary files /dev/null and b/.yarn/offline-mirror/autoprefixer-9.6.1.tgz differ
diff --git a/.yarn/offline-mirror/browserslist-4.7.0.tgz b/.yarn/offline-mirror/browserslist-4.7.0.tgz
new file mode 100644
index 000000000000..d2b013dc7857
Binary files /dev/null and b/.yarn/offline-mirror/browserslist-4.7.0.tgz differ
diff --git a/.yarn/offline-mirror/caniuse-lite-1.0.30000989.tgz b/.yarn/offline-mirror/caniuse-lite-1.0.30000989.tgz
new file mode 100644
index 000000000000..a4257f03e49c
Binary files /dev/null and b/.yarn/offline-mirror/caniuse-lite-1.0.30000989.tgz differ
diff --git a/.yarn/offline-mirror/clone-deep-4.0.1.tgz b/.yarn/offline-mirror/clone-deep-4.0.1.tgz
new file mode 100644
index 000000000000..5ade210df125
Binary files /dev/null and b/.yarn/offline-mirror/clone-deep-4.0.1.tgz differ
diff --git a/.yarn/offline-mirror/css-loader-3.2.0.tgz b/.yarn/offline-mirror/css-loader-3.2.0.tgz
new file mode 100644
index 000000000000..104aa6832d00
Binary files /dev/null and b/.yarn/offline-mirror/css-loader-3.2.0.tgz differ
diff --git a/.yarn/offline-mirror/electron-to-chromium-1.3.250.tgz b/.yarn/offline-mirror/electron-to-chromium-1.3.250.tgz
new file mode 100644
index 000000000000..b61df2d9745f
Binary files /dev/null and b/.yarn/offline-mirror/electron-to-chromium-1.3.250.tgz differ
diff --git a/.yarn/offline-mirror/is-reference-1.1.3.tgz b/.yarn/offline-mirror/is-reference-1.1.3.tgz
new file mode 100644
index 000000000000..0a5402d73e15
Binary files /dev/null and b/.yarn/offline-mirror/is-reference-1.1.3.tgz differ
diff --git a/.yarn/offline-mirror/node-releases-1.1.29.tgz b/.yarn/offline-mirror/node-releases-1.1.29.tgz
new file mode 100644
index 000000000000..867e31cd3b59
Binary files /dev/null and b/.yarn/offline-mirror/node-releases-1.1.29.tgz differ
diff --git a/.yarn/offline-mirror/postcss-modules-local-by-default-3.0.2.tgz b/.yarn/offline-mirror/postcss-modules-local-by-default-3.0.2.tgz
new file mode 100644
index 000000000000..de818ad66eaa
Binary files /dev/null and b/.yarn/offline-mirror/postcss-modules-local-by-default-3.0.2.tgz differ
diff --git a/.yarn/offline-mirror/postcss-modules-values-3.0.0.tgz b/.yarn/offline-mirror/postcss-modules-values-3.0.0.tgz
new file mode 100644
index 000000000000..80c45fdf8036
Binary files /dev/null and b/.yarn/offline-mirror/postcss-modules-values-3.0.0.tgz differ
diff --git a/.yarn/offline-mirror/react-16.9.0.tgz b/.yarn/offline-mirror/react-16.9.0.tgz
new file mode 100644
index 000000000000..0e82cf4e2d1e
Binary files /dev/null and b/.yarn/offline-mirror/react-16.9.0.tgz differ
diff --git a/.yarn/offline-mirror/react-dom-16.9.0.tgz b/.yarn/offline-mirror/react-dom-16.9.0.tgz
new file mode 100644
index 000000000000..37e085ad3dcb
Binary files /dev/null and b/.yarn/offline-mirror/react-dom-16.9.0.tgz differ
diff --git a/.yarn/offline-mirror/resolve-1.12.0.tgz b/.yarn/offline-mirror/resolve-1.12.0.tgz
new file mode 100644
index 000000000000..26739036dbf6
Binary files /dev/null and b/.yarn/offline-mirror/resolve-1.12.0.tgz differ
diff --git a/.yarn/offline-mirror/rollup-1.20.3.tgz b/.yarn/offline-mirror/rollup-1.20.3.tgz
new file mode 100644
index 000000000000..aa1440f57625
Binary files /dev/null and b/.yarn/offline-mirror/rollup-1.20.3.tgz differ
diff --git a/.yarn/offline-mirror/rollup-plugin-commonjs-10.1.0.tgz b/.yarn/offline-mirror/rollup-plugin-commonjs-10.1.0.tgz
new file mode 100644
index 000000000000..ebda5ebdf23c
Binary files /dev/null and b/.yarn/offline-mirror/rollup-plugin-commonjs-10.1.0.tgz differ
diff --git a/.yarn/offline-mirror/rollup-plugin-node-resolve-5.2.0.tgz b/.yarn/offline-mirror/rollup-plugin-node-resolve-5.2.0.tgz
new file mode 100644
index 000000000000..8e78e0afa74b
Binary files /dev/null and b/.yarn/offline-mirror/rollup-plugin-node-resolve-5.2.0.tgz differ
diff --git a/.yarn/offline-mirror/sass-loader-8.0.0.tgz b/.yarn/offline-mirror/sass-loader-8.0.0.tgz
new file mode 100644
index 000000000000..37a21776e442
Binary files /dev/null and b/.yarn/offline-mirror/sass-loader-8.0.0.tgz differ
diff --git a/.yarn/offline-mirror/schema-utils-2.2.0.tgz b/.yarn/offline-mirror/schema-utils-2.2.0.tgz
new file mode 100644
index 000000000000..b6fc48e1acb0
Binary files /dev/null and b/.yarn/offline-mirror/schema-utils-2.2.0.tgz differ
diff --git a/.yarn/offline-mirror/shallow-clone-3.0.1.tgz b/.yarn/offline-mirror/shallow-clone-3.0.1.tgz
new file mode 100644
index 000000000000..1b95b31d1e09
Binary files /dev/null and b/.yarn/offline-mirror/shallow-clone-3.0.1.tgz differ
diff --git a/.yarn/offline-mirror/style-loader-1.0.0.tgz b/.yarn/offline-mirror/style-loader-1.0.0.tgz
new file mode 100644
index 000000000000..d8bbf43d0c59
Binary files /dev/null and b/.yarn/offline-mirror/style-loader-1.0.0.tgz differ
diff --git a/packages/react-hooks/.npmignore b/packages/react-hooks/.npmignore
new file mode 100644
index 000000000000..81ba1598b971
--- /dev/null
+++ b/packages/react-hooks/.npmignore
@@ -0,0 +1,4 @@
+**/__mocks__/**
+**/__tests__/**
+**/examples/**
+**/tasks/**
\ No newline at end of file
diff --git a/packages/react-hooks/.storybook/_styles.scss b/packages/react-hooks/.storybook/_styles.scss
new file mode 100644
index 000000000000..6d4469d58f2c
--- /dev/null
+++ b/packages/react-hooks/.storybook/_styles.scss
@@ -0,0 +1,9 @@
+//
+// Copyright IBM Corp. 2016, 2018
+//
+// This source code is licensed under the Apache-2.0 license found in the
+// LICENSE file in the root directory of this source tree.
+//
+
+$css--helpers: true;
+@import '~carbon-components/scss/globals/scss/css--helpers';
diff --git a/packages/react-hooks/.storybook/addons.js b/packages/react-hooks/.storybook/addons.js
new file mode 100644
index 000000000000..4c6122223a52
--- /dev/null
+++ b/packages/react-hooks/.storybook/addons.js
@@ -0,0 +1,9 @@
+/**
+ * Copyright IBM Corp. 2018, 2018
+ *
+ * This source code is licensed under the Apache-2.0 license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import '@storybook/addon-actions/register';
+import '@storybook/addon-links/register';
diff --git a/packages/react-hooks/.storybook/config.js b/packages/react-hooks/.storybook/config.js
new file mode 100644
index 000000000000..b95837b24f3e
--- /dev/null
+++ b/packages/react-hooks/.storybook/config.js
@@ -0,0 +1,17 @@
+/**
+ * Copyright IBM Corp. 2018, 2018
+ *
+ * This source code is licensed under the Apache-2.0 license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import './_styles.scss';
+import { configure } from '@storybook/react';
+
+// automatically import all files ending in *.stories.js
+const req = require.context('../src', true, /-story\.js$/);
+function loadStories() {
+ req.keys().forEach(filename => req(filename));
+}
+
+configure(loadStories, module);
diff --git a/packages/react-hooks/.storybook/webpack.config.js b/packages/react-hooks/.storybook/webpack.config.js
new file mode 100644
index 000000000000..7d4ad9b0f0aa
--- /dev/null
+++ b/packages/react-hooks/.storybook/webpack.config.js
@@ -0,0 +1,42 @@
+/**
+ * Copyright IBM Corp. 2018, 2018
+ *
+ * This source code is licensed under the Apache-2.0 license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+const path = require('path');
+
+module.exports = ({ config, mode }) => {
+ config.module.rules.push({
+ test: /\.s?css$/,
+ sideEffects: true,
+ use: [
+ {
+ loader: 'style-loader',
+ },
+ {
+ loader: 'css-loader',
+ options: {
+ importLoaders: 2,
+ },
+ },
+ {
+ loader: 'postcss-loader',
+ options: {
+ plugins: [require('autoprefixer')],
+ },
+ },
+ {
+ loader: 'sass-loader',
+ options: {
+ sassOptions: {
+ includePaths: [path.resolve(__dirname, '..', 'node_modules')],
+ },
+ },
+ },
+ ],
+ });
+
+ return config;
+};
diff --git a/packages/react-hooks/package.json b/packages/react-hooks/package.json
new file mode 100644
index 000000000000..813eaa5912d2
--- /dev/null
+++ b/packages/react-hooks/package.json
@@ -0,0 +1,54 @@
+{
+ "name": "@carbon/react-hooks",
+ "private": true,
+ "version": "10.0.0",
+ "license": "Apache-2.0",
+ "main": "lib/index.js",
+ "module": "es/index.js",
+ "repository": "https://github.com/carbon-design-system/carbon/tree/master/packages/react-hooks",
+ "bugs": "https://github.com/carbon-design-system/carbon/issues",
+ "keywords": [
+ "ibm",
+ "carbon",
+ "carbon-design-system",
+ "components",
+ "react"
+ ],
+ "publishConfig": {
+ "access": "public"
+ },
+ "scripts": {
+ "build": "yarn clean && rollup -c",
+ "clean": "rimraf es lib",
+ "develop": "start-storybook -p 3000",
+ "watch": "yarn clean && rollup -c -w"
+ },
+ "peerDependencies": {
+ "react": "^16.9.0"
+ },
+ "devDependencies": {
+ "@babel/core": "^7.5.5",
+ "@babel/preset-env": "^7.5.5",
+ "@babel/preset-react": "^7.0.0",
+ "@storybook/addon-actions": "^5.1.11",
+ "@storybook/addon-links": "^5.1.11",
+ "@storybook/addons": "^5.1.11",
+ "@storybook/react": "^5.1.11",
+ "autoprefixer": "^9.6.1",
+ "babel-loader": "^8.0.6",
+ "browserslist-config-carbon": "10.4.0",
+ "carbon-components": "10.6.0",
+ "css-loader": "^3.2.0",
+ "node-sass": "^4.12.0",
+ "postcss-loader": "^3.0.0",
+ "react": "^16.9.0",
+ "react-dom": "^16.9.0",
+ "rimraf": "^3.0.0",
+ "rollup": "^1.20.3",
+ "rollup-plugin-babel": "^4.3.3",
+ "rollup-plugin-commonjs": "^10.1.0",
+ "rollup-plugin-node-resolve": "^5.2.0",
+ "sass-loader": "^8.0.0",
+ "style-loader": "^1.0.0"
+ }
+}
diff --git a/packages/react-hooks/rollup.config.js b/packages/react-hooks/rollup.config.js
new file mode 100644
index 000000000000..ca660df377c6
--- /dev/null
+++ b/packages/react-hooks/rollup.config.js
@@ -0,0 +1,55 @@
+/**
+ * Copyright IBM Corp. 2018, 2018
+ *
+ * This source code is licensed under the Apache-2.0 license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+'use strict';
+
+const resolve = require('rollup-plugin-node-resolve');
+const commonjs = require('rollup-plugin-commonjs');
+const babel = require('rollup-plugin-babel');
+const packageJson = require('./package.json');
+
+const baseConfig = {
+ input: './src/index.js',
+ external: Object.keys(packageJson.peerDependencies),
+ plugins: [
+ resolve(),
+ commonjs({
+ include: /node_modules/,
+ }),
+ babel({
+ babelrc: false,
+ presets: [
+ [
+ '@babel/preset-env',
+ {
+ targets: {
+ browsers: ['extends browserslist-config-carbon'],
+ },
+ },
+ ],
+ '@babel/preset-react',
+ ],
+ }),
+ ],
+};
+
+module.exports = [
+ {
+ ...baseConfig,
+ output: {
+ format: 'esm',
+ file: 'es/index.js',
+ },
+ },
+ {
+ ...baseConfig,
+ output: {
+ format: 'cjs',
+ file: 'lib/index.js',
+ },
+ },
+];
diff --git a/packages/react-hooks/src/__tests__/useAnnouncer-test.js b/packages/react-hooks/src/__tests__/useAnnouncer-test.js
new file mode 100644
index 000000000000..624cb962c9a3
--- /dev/null
+++ b/packages/react-hooks/src/__tests__/useAnnouncer-test.js
@@ -0,0 +1,92 @@
+/**
+ * Copyright IBM Corp. 2018, 2018
+ *
+ * This source code is licensed under the Apache-2.0 license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+jest.useFakeTimers();
+
+describe('useAnnouncer', () => {
+ let React;
+ let act;
+ let render;
+ let cleanup;
+ let useAnnouncer;
+
+ beforeEach(() => {
+ jest.resetModules();
+ React = require('react');
+ act = require('react-dom/test-utils').act;
+ render = require('../test-helpers').render;
+ cleanup = require('../test-helpers').cleanup;
+ useAnnouncer = require('../useAnnouncer').useAnnouncer;
+ });
+
+ afterEach(() => {
+ if (cleanup) {
+ cleanup();
+ }
+ });
+
+ it('should create a live region region for each aria-live mode', () => {
+ function Component() {
+ useAnnouncer();
+ return null;
+ }
+
+ act(() => {
+ render();
+ });
+
+ jest.runAllTimers();
+
+ expect(document.querySelector('[aria-live="polite"]')).toBeInstanceOf(
+ HTMLDivElement
+ );
+ expect(document.querySelector('[aria-live="assertive"]')).toBeInstanceOf(
+ HTMLDivElement
+ );
+ });
+
+ it('should update a live region for the given mode and announcement', () => {
+ const testMessage = 'test message';
+
+ function Component({ mode, message, testId }) {
+ const announce = useAnnouncer();
+ return (
+
+ );
+ }
+
+ let testId = 'announce-id-1';
+ act(() => {
+ render();
+ });
+
+ let button = document.querySelector(`[data-test-id="${testId}"]`);
+ button.click();
+
+ jest.runAllTimers();
+
+ const politeRegion = document.querySelector('[aria-live="polite"]');
+ expect(politeRegion.textContent).toEqual(testMessage);
+
+ testId = 'announce-id-2';
+ act(() => {
+ render(
+
+ );
+ });
+
+ button = document.querySelector(`[data-test-id="${testId}"]`);
+ button.click();
+
+ jest.runAllTimers();
+
+ const assertiveRegion = document.querySelector('[aria-live="assertive"]');
+ expect(assertiveRegion.textContent).toEqual(testMessage);
+ });
+});
diff --git a/packages/react-hooks/src/__tests__/useId-test.js b/packages/react-hooks/src/__tests__/useId-test.js
new file mode 100644
index 000000000000..0ad998728c82
--- /dev/null
+++ b/packages/react-hooks/src/__tests__/useId-test.js
@@ -0,0 +1,70 @@
+/**
+ * Copyright IBM Corp. 2018, 2018
+ *
+ * This source code is licensed under the Apache-2.0 license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+describe('useId', () => {
+ let React;
+ let render;
+ let cleanup;
+ let useId;
+
+ beforeEach(() => {
+ jest.resetModules();
+ React = require('react');
+ render = require('../test-helpers').render;
+ cleanup = require('../test-helpers').cleanup;
+ useId = require('../useId').useId;
+ });
+
+ afterEach(() => {
+ if (cleanup) {
+ cleanup();
+ }
+ });
+
+ it('should generate a unique id for each component', () => {
+ function Component() {
+ const id = useId();
+ return
;
+ }
+
+ const { container } = render(
+ <>
+
+
+ >
+ );
+
+ const ids = Array.from(container.childNodes).map(node => node.id);
+ const uniqueIds = new Set(ids);
+ expect(uniqueIds.size).toBe(2);
+ });
+
+ it('should keep the same id for each call to render', () => {
+ function Component() {
+ const id = useId();
+ return ;
+ }
+
+ const { container } = render();
+ const id = container.childNodes[0].id;
+
+ render();
+ expect(container.childNodes[0].id).toBe(id);
+ });
+
+ it('should include a prefix in the generated `id`', () => {
+ const prefix = 'prefix';
+ function Component() {
+ const id = useId(prefix);
+ return ;
+ }
+
+ const { container } = render();
+ const id = container.childNodes[0].id;
+ expect(id).toEqual(expect.stringContaining(prefix));
+ });
+});
diff --git a/packages/react-hooks/src/__tests__/usePortalNode-test.js b/packages/react-hooks/src/__tests__/usePortalNode-test.js
new file mode 100644
index 000000000000..5836fd3ee610
--- /dev/null
+++ b/packages/react-hooks/src/__tests__/usePortalNode-test.js
@@ -0,0 +1,89 @@
+/**
+ * Copyright IBM Corp. 2018, 2018
+ *
+ * This source code is licensed under the Apache-2.0 license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+describe('usePortalNode', () => {
+ let React;
+ let ReactDOM;
+ let act;
+ let render;
+ let cleanup;
+ let usePortalNode;
+
+ beforeEach(() => {
+ jest.resetModules();
+ React = require('react');
+ ReactDOM = require('react-dom');
+ act = require('react-dom/test-utils').act;
+ render = require('../test-helpers').render;
+ cleanup = require('../test-helpers').cleanup;
+ usePortalNode = require('../usePortalNode').usePortalNode;
+ });
+
+ afterEach(() => {
+ if (cleanup) {
+ cleanup();
+ }
+ });
+
+ it('should create a portal node', () => {
+ const testId = 'test-id';
+ let portalNode;
+ function Component() {
+ portalNode = usePortalNode();
+ return (
+ <>
+ Component
+ {portalNode &&
+ ReactDOM.createPortal(, portalNode)}
+ >
+ );
+ }
+
+ act(() => {
+ render();
+ });
+
+ expect(portalNode).toBeDefined();
+ // The portal node should exist in document.body
+ const children = Array.from(document.body.childNodes);
+ expect(children.indexOf(portalNode)).not.toBe(-1);
+
+ // The portal node should have rendered a node with our `data-test-id`
+ expect(
+ document.body.querySelector(`[data-test-id="${testId}"]`)
+ ).toBeDefined();
+ });
+
+ it('should create a new node when given an id', () => {
+ const id = 'test-id';
+ function Component() {
+ usePortalNode(id);
+ return null;
+ }
+
+ act(() => {
+ render();
+ });
+
+ expect(document.body.querySelector(id)).toBeDefined();
+ });
+
+ it('should reuse an existing node with a given id', () => {
+ const id = 'test-id';
+ function Component() {
+ usePortalNode(id);
+ return null;
+ }
+
+ act(() => {
+ const { rerender } = render();
+ rerender();
+ });
+
+ expect(document.body.querySelectorAll(`#${id}`).length).toBe(1);
+ });
+});
diff --git a/packages/react-hooks/src/index.js b/packages/react-hooks/src/index.js
new file mode 100644
index 000000000000..8debd14a43d1
--- /dev/null
+++ b/packages/react-hooks/src/index.js
@@ -0,0 +1,10 @@
+/**
+ * Copyright IBM Corp. 2018, 2018
+ *
+ * This source code is licensed under the Apache-2.0 license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+export * from './useAnnouncer';
+export * from './useId';
+export * from './usePortalNode';
diff --git a/packages/react-hooks/src/test-helpers.js b/packages/react-hooks/src/test-helpers.js
new file mode 100644
index 000000000000..f2c8565c8b14
--- /dev/null
+++ b/packages/react-hooks/src/test-helpers.js
@@ -0,0 +1,33 @@
+/**
+ * Copyright IBM Corp. 2018, 2018
+ *
+ * This source code is licensed under the Apache-2.0 license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import ReactDOM from 'react-dom';
+
+const containers = new Set();
+
+export function render(
+ element,
+ { container = document.createElement('div') } = {}
+) {
+ containers.add(container);
+ document.body.appendChild(container);
+ ReactDOM.render(element, container);
+ return {
+ container,
+ rerender() {
+ ReactDOM.render(element, container);
+ },
+ };
+}
+
+export function cleanup() {
+ for (const node of containers) {
+ ReactDOM.unmountComponentAtNode(node);
+ node.parentNode.removeChild(node);
+ containers.delete(node);
+ }
+}
diff --git a/packages/react-hooks/src/useAnnouncer-story.js b/packages/react-hooks/src/useAnnouncer-story.js
new file mode 100644
index 000000000000..9bceec679772
--- /dev/null
+++ b/packages/react-hooks/src/useAnnouncer-story.js
@@ -0,0 +1,109 @@
+/**
+ * Copyright IBM Corp. 2018, 2018
+ *
+ * This source code is licensed under the Apache-2.0 license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import { storiesOf } from '@storybook/react';
+import React, { useState } from 'react';
+import { useAnnouncer, usePoliteAnnouncer, useAssertiveAnnouncer } from './';
+
+storiesOf('useAnnouncer', module)
+ .add('default', () => {
+ function DemoComponent() {
+ const [mode, updateMode] = useState('polite');
+ const [announcement, updateAnnouncement] = useState('test message');
+ const announce = useAnnouncer();
+
+ function onModeChange(event) {
+ updateMode(event.target.value);
+ }
+
+ function onAnnouncementChange(event) {
+ updateAnnouncement(event.target.value);
+ }
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+ >
+ );
+ }
+ return ;
+ })
+ .add('polite announcer', () => {
+ function DemoComponent() {
+ const announce = usePoliteAnnouncer();
+ const [count, setCount] = useState(1);
+ function onClick() {
+ setCount(count + 1);
+ announce(`Polite message ${count}`);
+ }
+ return ;
+ }
+ return ;
+ })
+ .add('assertive announcer', () => {
+ function DemoComponent() {
+ const announce = useAssertiveAnnouncer();
+ const [count, setCount] = useState(1);
+ function onClick() {
+ setCount(count + 1);
+ announce(`Assertive message ${count}`);
+ }
+ return ;
+ }
+ return ;
+ })
+ .add('multiple announcers', () => {
+ function DemoComponent() {
+ return (
+ <>
+
+
+ >
+ );
+ }
+
+ function Assertive() {
+ const announce = useAnnouncer();
+ const [count, setCount] = useState(1);
+ function onClick() {
+ setCount(count + 1);
+ announce('assertive', `Assertive message ${count}`);
+ }
+ return ;
+ }
+
+ function Polite() {
+ const announce = useAnnouncer();
+ const [count, setCount] = useState(1);
+ function onClick() {
+ setCount(count + 1);
+ announce('polite', `Polite message ${count}`);
+ }
+ return ;
+ }
+
+ return ;
+ });
diff --git a/packages/react-hooks/src/useAnnouncer.js b/packages/react-hooks/src/useAnnouncer.js
new file mode 100644
index 000000000000..6f6a1b46f94d
--- /dev/null
+++ b/packages/react-hooks/src/useAnnouncer.js
@@ -0,0 +1,100 @@
+/**
+ * Copyright IBM Corp. 2018, 2018
+ *
+ * This source code is licensed under the Apache-2.0 license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import { useEffect, useState } from 'react';
+import { usePortalNode } from './usePortalNode';
+
+/**
+ * Provides an `announce` method that allows a user to queue up an assertive or
+ * polite message to the user. This message is displayed in an `aria-live`
+ * region with the appropriate mode and the message is set as its text content.
+ * This `aria-live` region is the same for all components, so ordering of
+ * messages sent is important.
+ */
+export function useAnnouncer() {
+ const node = usePortalNode('carbon-announcer');
+ const [mode, updateMode] = useState('polite');
+ const [announcement, updateAnnouncement] = useState('');
+
+ function announce(mode, message) {
+ updateMode(mode);
+ updateAnnouncement(message);
+ }
+
+ useEffect(() => {
+ if (!node) {
+ return;
+ }
+
+ if (!node.classList.contains('bx--visually-hidden')) {
+ node.classList.add('bx--visually-hidden');
+ }
+
+ // In this effect, we'll need to setup the `#carbon-announcer` node with two
+ // corresponding announcement nodes if they do not exist already. If they
+ // already exist, then we can reuse them.
+ let assertiveNode = node.querySelector('#carbon-assertive-announcement');
+ if (!assertiveNode) {
+ assertiveNode = document.createElement('div');
+ assertiveNode.id = 'carbon-assertive-announcement';
+ assertiveNode.setAttribute('aria-live', 'assertive');
+ node.appendChild(assertiveNode);
+ }
+
+ let politeNode = node.querySelector('#carbon-polite-announcement');
+ if (!politeNode) {
+ politeNode = document.createElement('div');
+ politeNode.id = 'carbon-polite-announcement';
+ politeNode.setAttribute('aria-live', 'polite');
+ node.appendChild(politeNode);
+ }
+ }, [node]);
+
+ useEffect(() => {
+ if (!node) {
+ return;
+ }
+
+ // Each time the mode or announcement changes, we'll want to update the
+ // message at that node.
+ const assertiveNode = node.querySelector('#carbon-assertive-announcement');
+ const politeNode = node.querySelector('#carbon-polite-announcement');
+ const timeoutId = setTimeout(() => {
+ if (mode === 'assertive' && assertiveNode.textContent !== announcement) {
+ assertiveNode.textContent = announcement;
+ }
+
+ if (mode === 'polite' && politeNode.textContent !== announcement) {
+ politeNode.textContent = announcement;
+ }
+ }, 300);
+
+ return () => {
+ window.clearTimeout(timeoutId);
+ };
+ }, [node, mode, announcement]);
+
+ return announce;
+}
+
+/**
+ * Provides an announce method that will allow the user to queue up messages in
+ * an `aria-live="assertive"` region
+ */
+export function useAssertiveAnnouncer() {
+ const announce = useAnnouncer();
+ return message => announce('assertive', message);
+}
+
+/**
+ * Provides an announce method that will allow the user to queue up messages in
+ * an `aria-live="polite"` region
+ */
+export function usePoliteAnnouncer() {
+ const announce = useAnnouncer();
+ return message => announce('polite', message);
+}
diff --git a/packages/react-hooks/src/useId-story.js b/packages/react-hooks/src/useId-story.js
new file mode 100644
index 000000000000..95a01b8eaa61
--- /dev/null
+++ b/packages/react-hooks/src/useId-story.js
@@ -0,0 +1,46 @@
+/**
+ * Copyright IBM Corp. 2018, 2018
+ *
+ * This source code is licensed under the Apache-2.0 license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import { storiesOf } from '@storybook/react';
+import React from 'react';
+import { useId } from './';
+
+storiesOf('useId', module)
+ .add('default', () => {
+ function DemoComponent() {
+ const id = useId();
+ return (
+
+ This node has an id of {id}
+
+ );
+ }
+ return ;
+ })
+ .add('with prefix', () => {
+ function List({ children }) {
+ const id = useId('list');
+ return
{children}
;
+ }
+
+ function ListItem() {
+ const id = useId('list-item');
+ return (
+
+ List item {id}
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ );
+ });
diff --git a/packages/react-hooks/src/useId.js b/packages/react-hooks/src/useId.js
new file mode 100644
index 000000000000..798225611440
--- /dev/null
+++ b/packages/react-hooks/src/useId.js
@@ -0,0 +1,38 @@
+/**
+ * Copyright IBM Corp. 2018, 2018
+ *
+ * This source code is licensed under the Apache-2.0 license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import { useRef } from 'react';
+
+let id = 0;
+
+/**
+ * Provides a unique identifier with an optional prefix, useful for dynamically
+ * creating `id` values for controls, especially alongside `aria-labelledby` or
+ * `htmlFor`. This `id` value is guaranteed to be the same for the duration of
+ * the component.
+ *
+ * @example
+ * function TextInput() {
+ * const id = useId('text-input');
+ * return (
+ *