From e346d01e48bdeef2d06eec83bbe47660be3f2e03 Mon Sep 17 00:00:00 2001
From: Jan Hassel <jan.hassel@ibm.com>
Date: Thu, 6 Oct 2022 17:41:14 +0200
Subject: [PATCH] feat: add contained-list component (#11969)

* feat(contained-list): scaffold new component

* feat(contained-list-item): scaffold new component

* feat(contained-list-item): add support for clickable items

* feat(contained-list): add support for disabled list items

* feat(contained-list): add support for actions in header and item

* docs(contained-list): build stories

* feat(contained-list-item): add support for icons

* test(contained-list): add tests

* refactor(contained-list): rename variants

* docs(contained-list): change "Heading" to "List title"

* fix(contained-list-item): add extra padding when action is pased

* docs(contained-list): change "Heading" to "List title"

Co-authored-by: Lauren Rice <43969356+laurenmrice@users.noreply.github.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
---
 .../__snapshots__/PublicAPI-test.js.snap      | 100 +++++++
 packages/react/src/__tests__/index-test.js    |   2 +
 .../components/ContainedList/ContainedList.js |  74 +++++
 .../ContainedListItem/ContainedListItem.js    |  96 ++++++
 .../ContainedList/ContainedListItem/index.js  |   8 +
 .../__tests__/ContainedList-test.js           | 137 +++++++++
 .../src/components/ContainedList/index.js     |  14 +
 .../next/ContainedList.stories.js             | 280 ++++++++++++++++++
 packages/react/src/index.js                   |   3 +
 packages/styles/scss/components/_index.scss   |   1 +
 .../contained-list/_contained-list.scss       | 174 +++++++++++
 .../components/contained-list/_index.scss     |  11 +
 12 files changed, 900 insertions(+)
 create mode 100644 packages/react/src/components/ContainedList/ContainedList.js
 create mode 100644 packages/react/src/components/ContainedList/ContainedListItem/ContainedListItem.js
 create mode 100644 packages/react/src/components/ContainedList/ContainedListItem/index.js
 create mode 100644 packages/react/src/components/ContainedList/__tests__/ContainedList-test.js
 create mode 100644 packages/react/src/components/ContainedList/index.js
 create mode 100644 packages/react/src/components/ContainedList/next/ContainedList.stories.js
 create mode 100644 packages/styles/scss/components/contained-list/_contained-list.scss
 create mode 100644 packages/styles/scss/components/contained-list/_index.scss

diff --git a/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap b/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap
index b078a5b06886..396eaf5a04a0 100644
--- a/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap
+++ b/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap
@@ -8639,6 +8639,106 @@ Map {
     "$$typeof": Symbol(react.forward_ref),
     "render": [Function],
   },
+  "unstable_ContainedList" => Object {
+    "ContainedListItem": Object {
+      "propTypes": Object {
+        "action": Object {
+          "type": "node",
+        },
+        "children": Object {
+          "type": "node",
+        },
+        "className": Object {
+          "type": "string",
+        },
+        "disabled": Object {
+          "type": "bool",
+        },
+        "onClick": Object {
+          "type": "func",
+        },
+        "renderIcon": Object {
+          "args": Array [
+            Array [
+              Object {
+                "type": "func",
+              },
+              Object {
+                "type": "object",
+              },
+            ],
+          ],
+          "type": "oneOfType",
+        },
+      },
+    },
+    "propTypes": Object {
+      "action": Object {
+        "type": "node",
+      },
+      "children": Object {
+        "type": "node",
+      },
+      "className": Object {
+        "type": "string",
+      },
+      "kind": Object {
+        "args": Array [
+          Array [
+            "on-page",
+            "disclosed",
+          ],
+        ],
+        "type": "oneOf",
+      },
+      "label": Object {
+        "args": Array [
+          Array [
+            Object {
+              "type": "string",
+            },
+            Object {
+              "type": "node",
+            },
+          ],
+        ],
+        "isRequired": true,
+        "type": "oneOfType",
+      },
+    },
+  },
+  "unstable_ContainedListItem" => Object {
+    "propTypes": Object {
+      "action": Object {
+        "type": "node",
+      },
+      "children": Object {
+        "type": "node",
+      },
+      "className": Object {
+        "type": "string",
+      },
+      "disabled": Object {
+        "type": "bool",
+      },
+      "onClick": Object {
+        "type": "func",
+      },
+      "renderIcon": Object {
+        "args": Array [
+          Array [
+            Object {
+              "type": "func",
+            },
+            Object {
+              "type": "object",
+            },
+          ],
+        ],
+        "type": "oneOfType",
+      },
+    },
+  },
   "unstable_FeatureFlags" => Object {
     "propTypes": Object {
       "children": Object {
diff --git a/packages/react/src/__tests__/index-test.js b/packages/react/src/__tests__/index-test.js
index 856fc691e02f..3490c5d4f003 100644
--- a/packages/react/src/__tests__/index-test.js
+++ b/packages/react/src/__tests__/index-test.js
@@ -214,6 +214,8 @@ describe('Carbon Components React', () => {
         "TreeView",
         "UnorderedList",
         "VStack",
+        "unstable_ContainedList",
+        "unstable_ContainedListItem",
         "unstable_FeatureFlags",
         "unstable_LayoutDirection",
         "unstable_Menu",
diff --git a/packages/react/src/components/ContainedList/ContainedList.js b/packages/react/src/components/ContainedList/ContainedList.js
new file mode 100644
index 000000000000..85440bdf5807
--- /dev/null
+++ b/packages/react/src/components/ContainedList/ContainedList.js
@@ -0,0 +1,74 @@
+/**
+ * Copyright IBM Corp. 2022
+ *
+ * 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 React from 'react';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+import { useId } from '../../internal/useId';
+import { usePrefix } from '../../internal/usePrefix';
+
+const variants = ['on-page', 'disclosed'];
+
+function ContainedList({
+  action,
+  children,
+  className,
+  kind = variants[0],
+  label,
+}) {
+  const labelId = `${useId('contained-list')}-header`;
+  const prefix = usePrefix();
+
+  const classes = classNames(
+    `${prefix}--contained-list`,
+    `${prefix}--contained-list--${kind}`,
+    className
+  );
+
+  return (
+    <div className={classes}>
+      <div className={`${prefix}--contained-list__header`}>
+        <div id={labelId} className={`${prefix}--contained-list__label`}>
+          {label}
+        </div>
+        {action && (
+          <div className={`${prefix}--contained-list__action`}>{action}</div>
+        )}
+      </div>
+      <ul aria-labelledby={labelId}>{children}</ul>
+    </div>
+  );
+}
+
+ContainedList.propTypes = {
+  /**
+   * A slot for a possible interactive element to render.
+   */
+  action: PropTypes.node,
+
+  /**
+   * A collection of ContainedListItems to be rendered in the ContainedList
+   */
+  children: PropTypes.node,
+
+  /**
+   * Additional CSS class names.
+   */
+  className: PropTypes.string,
+
+  /**
+   * The kind of ContainedList you want to display
+   */
+  kind: PropTypes.oneOf(variants),
+
+  /**
+   * A label describing the contained list.
+   */
+  label: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired,
+};
+
+export default ContainedList;
diff --git a/packages/react/src/components/ContainedList/ContainedListItem/ContainedListItem.js b/packages/react/src/components/ContainedList/ContainedListItem/ContainedListItem.js
new file mode 100644
index 000000000000..718f90ec285a
--- /dev/null
+++ b/packages/react/src/components/ContainedList/ContainedListItem/ContainedListItem.js
@@ -0,0 +1,96 @@
+/**
+ * Copyright IBM Corp. 2022
+ *
+ * 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 React from 'react';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+import { usePrefix } from '../../../internal/usePrefix';
+
+function ContainedListItem({
+  action,
+  children,
+  className,
+  disabled = false,
+  onClick,
+  renderIcon: IconElement,
+}) {
+  const prefix = usePrefix();
+
+  const isClickable = onClick !== undefined;
+
+  const classes = classNames(`${prefix}--contained-list-item`, className, {
+    [`${prefix}--contained-list-item--clickable`]: isClickable,
+    [`${prefix}--contained-list-item--with-icon`]: IconElement,
+    [`${prefix}--contained-list-item--with-action`]: action,
+  });
+
+  const content = (
+    <>
+      {IconElement && (
+        <div className={`${prefix}--contained-list-item__icon`}>
+          <IconElement />
+        </div>
+      )}
+      <div>{children}</div>
+    </>
+  );
+
+  return (
+    <li className={classes}>
+      {isClickable ? (
+        <button
+          className={`${prefix}--contained-list-item__content`}
+          type="button"
+          disabled={disabled}
+          onClick={onClick}>
+          {content}
+        </button>
+      ) : (
+        <div className={`${prefix}--contained-list-item__content`}>
+          {content}
+        </div>
+      )}
+      {action && (
+        <div className={`${prefix}--contained-list-item__action`}>{action}</div>
+      )}
+    </li>
+  );
+}
+
+ContainedListItem.propTypes = {
+  /**
+   * A slot for a possible interactive element to render within the item.
+   */
+  action: PropTypes.node,
+
+  /**
+   * The content of this item. Must not contain any interactive elements. Use props.action to include those.
+   */
+  children: PropTypes.node,
+
+  /**
+   * Additional CSS class names.
+   */
+  className: PropTypes.string,
+
+  /**
+   * Whether this item is disabled.
+   */
+  disabled: PropTypes.bool,
+
+  /**
+   * Provide an optional function to be called when the item is clicked.
+   */
+  onClick: PropTypes.func,
+
+  /**
+   * Provide an optional icon to render in front of the item's content.
+   */
+  renderIcon: PropTypes.oneOfType([PropTypes.func, PropTypes.object]),
+};
+
+export default ContainedListItem;
diff --git a/packages/react/src/components/ContainedList/ContainedListItem/index.js b/packages/react/src/components/ContainedList/ContainedListItem/index.js
new file mode 100644
index 000000000000..65d76124448c
--- /dev/null
+++ b/packages/react/src/components/ContainedList/ContainedListItem/index.js
@@ -0,0 +1,8 @@
+/**
+ * Copyright IBM Corp. 2022
+ *
+ * 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 default from './ContainedListItem';
diff --git a/packages/react/src/components/ContainedList/__tests__/ContainedList-test.js b/packages/react/src/components/ContainedList/__tests__/ContainedList-test.js
new file mode 100644
index 000000000000..0aa7ea9cc90b
--- /dev/null
+++ b/packages/react/src/components/ContainedList/__tests__/ContainedList-test.js
@@ -0,0 +1,137 @@
+/**
+ * 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.
+ */
+
+import React from 'react';
+import ContainedList, { ContainedListItem } from '../';
+import { render } from '@testing-library/react';
+
+const prefix = 'cds';
+
+const defaultProps = {
+  list: {
+    label: 'Heading',
+  },
+  item: {
+    children: 'List item',
+  },
+};
+let wrapper;
+
+function TestComponent({ list, item }) {
+  const props = {
+    list: {
+      ...defaultProps.list,
+      ...list,
+    },
+    item: {
+      ...defaultProps.item,
+      ...item,
+    },
+  };
+
+  return (
+    <ContainedList {...props.list}>
+      <ContainedListItem {...props.item} />
+    </ContainedList>
+  );
+}
+
+beforeEach(() => {
+  wrapper = render(<TestComponent />);
+});
+
+async function a11y(label) {
+  it('should have no Axe violations', async () => {
+    await expect(wrapper.container).toHaveNoAxeViolations();
+  });
+
+  it('should have no Accessibility Checker violations', async () => {
+    await expect(wrapper.container).toHaveNoACViolations(label);
+  });
+}
+
+describe('ContainedList', () => {
+  it('list and label ids match', () => {
+    const list = wrapper.getByRole('list');
+    const label = wrapper.container.querySelector(
+      `.${prefix}--contained-list__label`
+    );
+
+    expect(list.getAttribute('aria-labelledby')).toBe(label.id);
+  });
+
+  it('renders props.label', () => {
+    const label = wrapper.container.querySelector(
+      `.${prefix}--contained-list__label`
+    );
+
+    expect(label.textContent).toBe(defaultProps.list.label);
+  });
+
+  it('supports additional css class names', () => {
+    const className = 'some-class';
+    wrapper.rerender(<TestComponent list={{ className }} />);
+
+    expect(wrapper.container.firstChild.classList.contains(className)).toBe(
+      true
+    );
+  });
+
+  a11y('ContainedList');
+});
+
+describe('ContainedListItem', () => {
+  it('renders props.children', () => {
+    const content = wrapper.getByRole('listitem');
+
+    expect(content.textContent).toBe(defaultProps.item.children);
+  });
+
+  it('supports additional css class names', () => {
+    const className = 'some-class';
+    wrapper.rerender(<TestComponent item={{ className }} />);
+
+    expect(wrapper.getByRole('listitem').classList.contains(className)).toBe(
+      true
+    );
+  });
+
+  it('renders props.action adjacent to content', () => {
+    wrapper.rerender(
+      <TestComponent item={{ action: <div data-testid="action" /> }} />
+    );
+    const contentEl = wrapper.container.querySelector(
+      `.${prefix}--contained-list-item__content`
+    );
+
+    expect(contentEl.nextSibling.firstChild.dataset['testid']).toBe('action');
+  });
+
+  it('supports props.renderIcon', () => {
+    wrapper.rerender(
+      <TestComponent item={{ renderIcon: () => <svg data-testid="svg" /> }} />
+    );
+
+    expect(wrapper.container.querySelector('svg').dataset['testid']).toBe(
+      'svg'
+    );
+  });
+
+  describe('interactive', () => {
+    beforeEach(() => {
+      wrapper.rerender(<TestComponent item={{ onClick: () => {} }} />);
+    });
+
+    it('renders content as button', () => {
+      const content = wrapper.getByRole('listitem').firstChild;
+
+      expect(content.tagName).toBe('BUTTON');
+    });
+
+    a11y('ContainedListItem, interactive');
+  });
+});
diff --git a/packages/react/src/components/ContainedList/index.js b/packages/react/src/components/ContainedList/index.js
new file mode 100644
index 000000000000..15087cf386d9
--- /dev/null
+++ b/packages/react/src/components/ContainedList/index.js
@@ -0,0 +1,14 @@
+/**
+ * Copyright IBM Corp. 2022
+ *
+ * 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 ContainedList from './ContainedList';
+import ContainedListItem from './ContainedListItem';
+
+ContainedList.ContainedListItem = ContainedListItem;
+
+export { ContainedListItem };
+export default ContainedList;
diff --git a/packages/react/src/components/ContainedList/next/ContainedList.stories.js b/packages/react/src/components/ContainedList/next/ContainedList.stories.js
new file mode 100644
index 000000000000..6a2541bb3cef
--- /dev/null
+++ b/packages/react/src/components/ContainedList/next/ContainedList.stories.js
@@ -0,0 +1,280 @@
+/**
+ * Copyright IBM Corp. 2022
+ *
+ * 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 React from 'react';
+
+import { action } from '@storybook/addon-actions';
+import {
+  Apple,
+  Fish,
+  Information,
+  Strawberry,
+  SubtractAlt,
+  Wheat,
+} from '@carbon/icons-react';
+import { VStack } from '../../Stack';
+import Button from '../../Button';
+import ExpandableSearch from '../../ExpandableSearch';
+import Tag from '../../Tag';
+import { Tooltip } from '../../Tooltip/next';
+
+import ContainedList, { ContainedListItem } from '../';
+
+export default {
+  title: 'Experimental/unstable_ContainedList',
+  component: ContainedList,
+};
+
+export const OnPage = () => (
+  <>
+    <ContainedList label="List title" kind="on-page">
+      <ContainedListItem>List item</ContainedListItem>
+      <ContainedListItem>List item</ContainedListItem>
+      <ContainedListItem>List item</ContainedListItem>
+      <ContainedListItem>List item</ContainedListItem>
+    </ContainedList>
+    <ContainedList label="List title" kind="on-page">
+      <ContainedListItem>List item</ContainedListItem>
+      <ContainedListItem>List item</ContainedListItem>
+      <ContainedListItem>List item</ContainedListItem>
+      <ContainedListItem>List item</ContainedListItem>
+    </ContainedList>
+  </>
+);
+
+export const Disclosed = () => (
+  <>
+    <ContainedList label="List title" kind="disclosed">
+      <ContainedListItem>List item</ContainedListItem>
+      <ContainedListItem>List item</ContainedListItem>
+      <ContainedListItem>List item</ContainedListItem>
+      <ContainedListItem>List item</ContainedListItem>
+    </ContainedList>
+    <ContainedList label="List title" kind="disclosed">
+      <ContainedListItem>List item</ContainedListItem>
+      <ContainedListItem>List item</ContainedListItem>
+      <ContainedListItem>List item</ContainedListItem>
+      <ContainedListItem>List item</ContainedListItem>
+    </ContainedList>
+  </>
+);
+
+export const Interactive = () => {
+  const onClick = action('onClick (ContainedListItem)');
+
+  return (
+    <VStack gap={12}>
+      <ContainedList label="List title" kind="on-page">
+        <ContainedListItem onClick={onClick}>List item</ContainedListItem>
+        <ContainedListItem onClick={onClick} disabled>
+          List item
+        </ContainedListItem>
+        <ContainedListItem onClick={onClick}>List item</ContainedListItem>
+        <ContainedListItem onClick={onClick}>List item</ContainedListItem>
+      </ContainedList>
+      <ContainedList label="List title" kind="disclosed">
+        <ContainedListItem onClick={onClick}>List item</ContainedListItem>
+        <ContainedListItem onClick={onClick} disabled>
+          List item
+        </ContainedListItem>
+        <ContainedListItem onClick={onClick}>List item</ContainedListItem>
+        <ContainedListItem onClick={onClick}>List item</ContainedListItem>
+      </ContainedList>
+    </VStack>
+  );
+};
+
+export const Actions = () => {
+  const itemAction = (
+    <Button
+      kind="ghost"
+      iconDescription="Dismiss"
+      hasIconOnly
+      renderIcon={SubtractAlt}
+    />
+  );
+
+  return (
+    <VStack gap={12}>
+      <ContainedList
+        label="List title"
+        kind="on-page"
+        action={<ExpandableSearch placeholder="Find item" size="lg" />}>
+        <ContainedListItem action={itemAction}>List item</ContainedListItem>
+        <ContainedListItem action={itemAction}>List item</ContainedListItem>
+        <ContainedListItem action={itemAction}>List item</ContainedListItem>
+        <ContainedListItem action={itemAction}>List item</ContainedListItem>
+      </ContainedList>
+      <ContainedList
+        label="List title"
+        kind="disclosed"
+        action={
+          <Button kind="ghost" size="sm">
+            Dismiss all
+          </Button>
+        }>
+        <ContainedListItem action={itemAction}>List item</ContainedListItem>
+        <ContainedListItem action={itemAction} disabled>
+          List item
+        </ContainedListItem>
+        <ContainedListItem action={itemAction}>List item</ContainedListItem>
+        <ContainedListItem action={itemAction}>List item</ContainedListItem>
+      </ContainedList>
+    </VStack>
+  );
+};
+
+export const ActionsInteractive = () => {
+  const onClick = action('onClick (ContainedListItem)');
+  const itemAction = (
+    <Button
+      kind="ghost"
+      iconDescription="Dismiss"
+      hasIconOnly
+      renderIcon={SubtractAlt}
+    />
+  );
+
+  return (
+    <VStack gap={12}>
+      <ContainedList
+        label="List title"
+        kind="on-page"
+        action={<ExpandableSearch placeholder="Find item" size="lg" />}>
+        <ContainedListItem action={itemAction} onClick={onClick}>
+          List item
+        </ContainedListItem>
+        <ContainedListItem action={itemAction} onClick={onClick}>
+          List item
+        </ContainedListItem>
+        <ContainedListItem action={itemAction} onClick={onClick}>
+          List item
+        </ContainedListItem>
+        <ContainedListItem action={itemAction} onClick={onClick}>
+          List item
+        </ContainedListItem>
+      </ContainedList>
+      <ContainedList
+        label="List title"
+        kind="disclosed"
+        action={
+          <Button kind="ghost" size="sm">
+            Dismiss all
+          </Button>
+        }>
+        <ContainedListItem action={itemAction} onClick={onClick}>
+          List item
+        </ContainedListItem>
+        <ContainedListItem action={itemAction} onClick={onClick}>
+          List item
+        </ContainedListItem>
+        <ContainedListItem action={itemAction} onClick={onClick}>
+          List item
+        </ContainedListItem>
+        <ContainedListItem action={itemAction} onClick={onClick}>
+          List item
+        </ContainedListItem>
+      </ContainedList>
+    </VStack>
+  );
+};
+
+export const ListTitleDecorators = () => {
+  return (
+    <VStack gap={12}>
+      <ContainedList
+        label={
+          <div
+            style={{
+              display: 'flex',
+              alignItems: 'center',
+              justifyContent: 'space-between',
+            }}>
+            <span>List title</span>
+            <Tag size="sm">4</Tag>
+          </div>
+        }
+        kind="on-page">
+        <ContainedListItem>List item</ContainedListItem>
+        <ContainedListItem>List item</ContainedListItem>
+        <ContainedListItem>List item</ContainedListItem>
+        <ContainedListItem>List item</ContainedListItem>
+      </ContainedList>
+      <ContainedList
+        label={
+          <div style={{ display: 'flex', alignItems: 'center' }}>
+            <span>List title</span>
+            <Tooltip align="top" label="Tooltip content">
+              <button
+                className="sb-tooltip-trigger"
+                style={{ color: 'inherit', border: 'none' }}
+                type="button">
+                <Information style={{ fill: 'currentColor' }} />
+              </button>
+            </Tooltip>
+          </div>
+        }
+        kind="disclosed">
+        <ContainedListItem>List item</ContainedListItem>
+        <ContainedListItem>List item</ContainedListItem>
+        <ContainedListItem>List item</ContainedListItem>
+        <ContainedListItem>List item</ContainedListItem>
+      </ContainedList>
+    </VStack>
+  );
+};
+
+export const Icons = () => {
+  return (
+    <VStack gap={12}>
+      <ContainedList label="List title" kind="on-page">
+        <ContainedListItem renderIcon={Apple}>List item</ContainedListItem>
+        <ContainedListItem renderIcon={Wheat}>List item</ContainedListItem>
+        <ContainedListItem renderIcon={Strawberry}>List item</ContainedListItem>
+        <ContainedListItem renderIcon={Fish}>List item</ContainedListItem>
+      </ContainedList>
+      <ContainedList label="List title" kind="disclosed">
+        <ContainedListItem renderIcon={Apple}>List item</ContainedListItem>
+        <ContainedListItem renderIcon={Wheat}>List item</ContainedListItem>
+        <ContainedListItem renderIcon={Strawberry}>List item</ContainedListItem>
+        <ContainedListItem renderIcon={Fish}>List item</ContainedListItem>
+      </ContainedList>
+    </VStack>
+  );
+};
+
+const PlaygroundStory = (args) => (
+  <>
+    {[...Array(4)].map((_, i) => (
+      <ContainedList key={i} {...args}>
+        {[...Array(8)].map((_, j) => (
+          <ContainedListItem key={`${i}-${j}`}>List item</ContainedListItem>
+        ))}
+      </ContainedList>
+    ))}
+  </>
+);
+
+export const Playground = PlaygroundStory.bind({});
+
+Playground.argTypes = {
+  action: {
+    control: false,
+  },
+  children: {
+    control: false,
+  },
+  className: {
+    control: false,
+  },
+  label: {
+    defaultValue: 'List title',
+  },
+  kind: {
+    defaultValue: 'on-page',
+  },
+};
diff --git a/packages/react/src/index.js b/packages/react/src/index.js
index 8a6fc7082c96..da42916bf17d 100644
--- a/packages/react/src/index.js
+++ b/packages/react/src/index.js
@@ -205,6 +205,9 @@ export {
 } from './components/UIShell';
 
 // Experimental
+export unstable_ContainedList, {
+  ContainedListItem as unstable_ContainedListItem,
+} from './components/ContainedList';
 export { useContextMenu as unstable_useContextMenu } from './components/ContextMenu';
 export {
   FeatureFlags as unstable_FeatureFlags,
diff --git a/packages/styles/scss/components/_index.scss b/packages/styles/scss/components/_index.scss
index 5992543d7791..c9dd4183726d 100644
--- a/packages/styles/scss/components/_index.scss
+++ b/packages/styles/scss/components/_index.scss
@@ -12,6 +12,7 @@
 @use 'checkbox';
 @use 'code-snippet';
 @use 'combo-box';
+@use 'contained-list';
 @use 'content-switcher';
 @use 'copy-button';
 @use 'data-table';
diff --git a/packages/styles/scss/components/contained-list/_contained-list.scss b/packages/styles/scss/components/contained-list/_contained-list.scss
new file mode 100644
index 000000000000..8d8958bf86cf
--- /dev/null
+++ b/packages/styles/scss/components/contained-list/_contained-list.scss
@@ -0,0 +1,174 @@
+//
+// Copyright IBM Corp. 2022
+//
+// 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 '../../config' as *;
+@use '../../motion' as *;
+@use '../../spacing' as *;
+@use '../../theme' as *;
+@use '../../type' as *;
+@use '../../utilities/convert' as *;
+@use '../../utilities/focus-outline' as *;
+@use '../../utilities/button-reset';
+
+/// Contained List styles
+/// @access public
+/// @group contained-list
+@mixin contained-list {
+  .#{$prefix}--contained-list__header {
+    position: sticky;
+    z-index: 1;
+    top: 0;
+    display: flex;
+    align-items: center;
+    padding-inline: $spacing-05;
+  }
+
+  .#{$prefix}--contained-list__label {
+    width: 100%;
+  }
+
+  // "On Page" variant
+
+  .#{$prefix}--contained-list--on-page + .#{$prefix}--contained-list--on-page {
+    margin-block-start: $spacing-05;
+  }
+
+  .#{$prefix}--contained-list--on-page .#{$prefix}--contained-list__header {
+    @include type-style('heading-compact-01');
+
+    height: $spacing-09;
+    border-bottom: 1px solid $border-subtle;
+    background-color: $background;
+    color: $text-primary;
+  }
+
+  .#{$prefix}--layer-two
+    .#{$prefix}--contained-list--on-page
+    .#{$prefix}--contained-list__header {
+    background-color: $layer-01;
+  }
+
+  .#{$prefix}--layer-three
+    .#{$prefix}--contained-list--on-page
+    .#{$prefix}--contained-list__header {
+    background-color: $layer-02;
+  }
+
+  // "Disclosed" variant
+
+  .#{$prefix}--contained-list--disclosed .#{$prefix}--contained-list__header {
+    @include type-style('label-01');
+
+    height: $spacing-07;
+    background-color: $layer;
+    color: $text-secondary;
+  }
+
+  // List item
+
+  .#{$prefix}--contained-list-item {
+    position: relative;
+  }
+
+  .#{$prefix}--contained-list-item:not(:first-of-type) {
+    margin-top: -1px;
+  }
+
+  .#{$prefix}--contained-list-item--clickable
+    .#{$prefix}--contained-list-item__content {
+    @include button-reset.reset;
+
+    text-align: start;
+    transition: background-color $duration-moderate-01
+      motion(standard, productive);
+  }
+
+  .#{$prefix}--contained-list-item__content,
+  .#{$prefix}--contained-list-item--clickable
+    .#{$prefix}--contained-list-item__content {
+    @include type-style('body-01');
+
+    padding: calc(#{$spacing-05} - #{rem(2px)}) $spacing-05;
+    color: $text-primary;
+  }
+
+  .#{$prefix}--contained-list-item:not(:last-of-type)::before {
+    position: absolute;
+    right: $spacing-05;
+    bottom: 0;
+    left: $spacing-05;
+    height: 1px;
+    background-color: $border-subtle;
+    content: '';
+  }
+
+  .#{$prefix}--contained-list-item--clickable
+    .#{$prefix}--contained-list-item__content:not(:disabled):hover {
+    background-color: $layer-hover;
+  }
+
+  .#{$prefix}--contained-list-item--clickable
+    .#{$prefix}--contained-list-item__content:not(:disabled):active {
+    background-color: $layer-active;
+  }
+
+  .#{$prefix}--contained-list-item--clickable
+    .#{$prefix}--contained-list-item__content:disabled {
+    color: $text-disabled;
+    cursor: not-allowed;
+  }
+
+  .#{$prefix}--contained-list-item--clickable
+    .#{$prefix}--contained-list-item__content:focus {
+    outline: none;
+  }
+
+  .#{$prefix}--contained-list-item--clickable
+    .#{$prefix}--contained-list-item__content:focus::after {
+    @include focus-outline('outline');
+
+    position: absolute;
+    top: 0;
+    right: 0;
+    bottom: 0;
+    left: 0;
+    content: '';
+  }
+
+  .#{$prefix}--contained-list-item--with-action
+    .#{$prefix}--contained-list-item__content {
+    padding-inline-end: $spacing-10;
+  }
+
+  .#{$prefix}--contained-list__action,
+  .#{$prefix}--contained-list-item__action {
+    position: absolute;
+    top: 0;
+    right: 0;
+    left: 0;
+    display: flex;
+    justify-content: flex-end;
+    pointer-events: none;
+  }
+
+  .#{$prefix}--contained-list__action > *,
+  .#{$prefix}--contained-list-item__action > * {
+    pointer-events: all;
+  }
+
+  .#{$prefix}--contained-list-item--with-icon
+    .#{$prefix}--contained-list-item__content {
+    display: grid;
+    column-gap: $spacing-04;
+    grid-template-columns: 1rem 1fr;
+  }
+
+  .#{$prefix}--contained-list-item__icon {
+    display: inline-flex;
+    padding-top: $spacing-01;
+  }
+}
diff --git a/packages/styles/scss/components/contained-list/_index.scss b/packages/styles/scss/components/contained-list/_index.scss
new file mode 100644
index 000000000000..1ac61680c5dd
--- /dev/null
+++ b/packages/styles/scss/components/contained-list/_index.scss
@@ -0,0 +1,11 @@
+//
+// Copyright IBM Corp. 2022
+//
+// This source code is licensed under the Apache-2.0 license found in the
+// LICENSE file in the root directory of this source tree.
+//
+
+@forward 'contained-list';
+@use 'contained-list';
+
+@include contained-list.contained-list;