diff --git a/src-docs/src/routes.js b/src-docs/src/routes.js
index 2ef7c4f5a..2f025edcc 100644
--- a/src-docs/src/routes.js
+++ b/src-docs/src/routes.js
@@ -1,12 +1,6 @@
/*
+ * Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
- *
- * The OpenSearch Contributors require contributions made to
- * this file be licensed under the Apache-2.0 license or a
- * compatible open source license.
- *
- * Modifications Copyright OpenSearch Contributors. See
- * GitHub history for details.
*/
import React, { createElement, Fragment } from 'react';
@@ -199,6 +193,8 @@ import { SideNavExample } from './views/side_nav/side_nav_example';
import { SpacerExample } from './views/spacer/spacer_example';
+import { SplitButtonExample } from './views/split_button/split_button_example';
+
import { StatExample } from './views/stat/stat_example';
import { StepsExample } from './views/steps/steps_example';
@@ -356,6 +352,7 @@ const navigation = [
items: [
BreadcrumbsExample,
ButtonExample,
+ SplitButtonExample,
CollapsibleNavExample,
ContextMenuExample,
ControlBarExample,
diff --git a/src-docs/src/views/split_button/split_button_basic.js b/src-docs/src/views/split_button/split_button_basic.js
new file mode 100644
index 000000000..21f6fc4c7
--- /dev/null
+++ b/src-docs/src/views/split_button/split_button_basic.js
@@ -0,0 +1,27 @@
+/*
+ * copyright opensearch contributors
+ * spdx-license-identifier: apache-2.0
+ */
+
+import React from 'react';
+
+import { OuiSplitButton } from '../../../../src/components';
+
+export default () => {
+ const options = [
+ { id: '1', display: 'Option 1', href: '#' },
+ {
+ id: '2',
+ display: 'Option 2',
+ onClick: () => console.log('Option 2 clicked'),
+ },
+ ];
+
+ const primaryClick = () => console.log('Primary clicked');
+
+ return (
+
+ Basic Split Button
+
+ );
+};
diff --git a/src-docs/src/views/split_button/split_button_change_demo.js b/src-docs/src/views/split_button/split_button_change_demo.js
new file mode 100644
index 000000000..2c936d9f9
--- /dev/null
+++ b/src-docs/src/views/split_button/split_button_change_demo.js
@@ -0,0 +1,58 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import React, { Fragment, useState } from 'react';
+
+import { OuiSplitButton, OuiText } from '../../../../src/components';
+
+export default () => {
+ const [selectedIndex, setSelectedIndex] = useState(0);
+
+ const options = [
+ {
+ display: (
+
+ Option one
+
+ Has a short description giving more detail to the option.
+
+
+ ),
+ button: 'Option one',
+ onClick: () => setSelectedIndex(0),
+ onButtonClick: () => console.log('Option one clicked'),
+ },
+ {
+ display: (
+
+ Option two
+
+ Has a short description giving more detail to the option.
+
+
+ ),
+ button: 'Option two',
+ onClick: () => setSelectedIndex(1),
+ onButtonClick: () => console.log('Option two clicked'),
+ },
+ {
+ display: 'Just some Text',
+ button: 'Option three',
+ onClick: () => setSelectedIndex(2),
+ onButtonClick: () => console.log('Option three clicked'),
+ },
+ ];
+
+ return (
+
+ {options[selectedIndex].button}
+
+ );
+};
diff --git a/src-docs/src/views/split_button/split_button_complex.js b/src-docs/src/views/split_button/split_button_complex.js
new file mode 100644
index 000000000..356b7cf1f
--- /dev/null
+++ b/src-docs/src/views/split_button/split_button_complex.js
@@ -0,0 +1,49 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import React, { Fragment } from 'react';
+
+import { OuiSplitButton, OuiText } from '../../../../src/components';
+
+export default () => {
+ const options = [
+ {
+ display: (
+
+ Option one
+
+ Has a short description giving more detail to the option.
+
+
+ ),
+ onClick: () => console.log('Option one clicked'),
+ },
+ {
+ display: (
+
+ Option two
+
+ Has a short description giving more detail to the option.
+
+
+ ),
+ onClick: () => console.log('Option 2 clicked'),
+ },
+ {
+ display: 'Just some Text',
+ onClick: () => console.log('Option 3 Clicked'),
+ },
+ ];
+
+ return (
+
+ Complex Selections
+
+ );
+};
diff --git a/src-docs/src/views/split_button/split_button_example.js b/src-docs/src/views/split_button/split_button_example.js
new file mode 100644
index 000000000..69f0d89cd
--- /dev/null
+++ b/src-docs/src/views/split_button/split_button_example.js
@@ -0,0 +1,255 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import React from 'react';
+
+import { renderToHtml } from '../../services';
+
+import { GuideSectionTypes } from '../../components';
+
+import { OuiCode, OuiSplitButton } from '../../../../src/components';
+
+import SplitButtonBasic from './split_button_basic';
+const splitButtonBasicSource = require('!!raw-loader!./split_button_basic');
+const splitButtonBasicHtml = renderToHtml(SplitButtonBasic);
+const splitButtonBasicSnippet = ` console.log('Option 2 clicked')
+ },
+ ]}
+ onClick={() => console.log("Primary clicked")}
+>Basic Split Button
+`;
+
+import SplitButtonComplex from './split_button_complex';
+const splitButtonComplexSource = require('!!raw-loader!./split_button_complex');
+const splitButtonComplexHtml = renderToHtml(SplitButtonComplex);
+const splitButtonComplexSnippet = `
+ Option one
+
+ Has a short description giving more detail to the option.
+
+
+ ),
+ onClick: () => console.log('Option one clicked'),
+ },
+ {
+ display: (
+
+ Option two
+
+ Has a short description giving more detail to the option.
+
+
+ ),
+ onClick: () => console.log('Option 2 clicked'),
+ },
+ {
+ display: 'Just some Text',
+ onClick: () => console.log('Option 3 Clicked'),
+ },
+ ]},
+ hasDividers
+ selectedIndex={1}
+>
+ Complex Selections
+
+`;
+
+import SplitButtonStates from './split_button_states';
+const splitButtonStatesSource = require('!!raw-loader!./split_button_states');
+const splitButtonStatesHtml = renderToHtml(SplitButtonStates);
+const splitButtonStatesSnippet = `
+`;
+
+import SplitButtonChangeDemo from './split_button_change_demo';
+const splitButtonChangeDemoSource = require('!!raw-loader!./split_button_change_demo');
+const splitButtonChangeDemoHtml = renderToHtml(SplitButtonChangeDemo);
+const splitButtonChangeDemoSnippet = `export default () => {
+ const [selectedIndex, setSelectedIndex] = useState(0);
+
+ const options = [
+ {
+ display: (
+
+ Option one
+
+ Has a short description giving more detail to the option.
+
+
+ ),
+ button: 'Option one',
+ onClick: () => setSelectedIndex(0),
+ onButtonClick: () => console.log('Option one clicked'),
+ },
+ {
+ display: (
+
+ Option two
+
+ Has a short description giving more detail to the option.
+
+
+ ),
+ button: 'Option two',
+ onClick: () => setSelectedIndex(1),
+ onButtonClick: () => console.log('Option two clicked'),
+ },
+ {
+ display: 'Just some Text',
+ button: 'Option three',
+ onClick: () => setSelectedIndex(2),
+ onButtonClick: () => console.log('Option three clicked'),
+ },
+ ];
+
+ return (
+
+ {options[selectedIndex].button}
+
+ );
+};
+`;
+
+export const SplitButtonExample = {
+ title: 'Split Button',
+ sections: [
+ {
+ source: [
+ {
+ type: GuideSectionTypes.JS,
+ code: splitButtonBasicSource,
+ },
+ {
+ type: GuideSectionTypes.HTML,
+ code: splitButtonBasicHtml,
+ },
+ ],
+ text: (
+
+
+ This is a replacement component for OuiButton if
+ you need a Button with additional options or modes. Simply pass an
+ array of options:
+
+
+
+ display : string or ReactNode - what shows for
+ the item in the dropdown
+
+
+ onClick : (optional) handler to call when this
+ item is clicked
+
+
+ href : (optional) URL to follow when this item
+ is clicked
+
+
+ target : (optional) along with href, browser
+ target to apply to link
+
+
+
+ … and the component will create a select styled button that
+ triggers a popover of selectable items.
+
+
+ ),
+ props: { OuiSplitButton },
+ snippet: splitButtonBasicSnippet,
+ demo: ,
+ },
+ {
+ title: 'More complex',
+ source: [
+ {
+ type: GuideSectionTypes.JS,
+ code: splitButtonComplexSource,
+ },
+ {
+ type: GuideSectionTypes.HTML,
+ code: splitButtonComplexHtml,
+ },
+ ],
+ text: (
+
+ options accept React nodes. Therefore you can pass
+ some descriptions with each option to show in the dropdown. If your
+ options will most likely be multi-line, add the{' '}
+ hasDividers prop to show borders between options.
+
+ ),
+ props: {},
+ snippet: splitButtonComplexSnippet,
+ demo: ,
+ },
+ {
+ title: 'States',
+ source: [
+ {
+ type: GuideSectionTypes.JS,
+ code: splitButtonStatesSource,
+ },
+ {
+ type: GuideSectionTypes.HTML,
+ code: splitButtonStatesHtml,
+ },
+ ],
+ text: (
+
+ You can pass the same props as you normally would to{' '}
+ OuiButton like fill, size, etc…
+
+ ),
+ props: { OuiSplitButton },
+ snippet: splitButtonStatesSnippet,
+ demo: ,
+ },
+ {
+ title: 'Change Primary Button',
+ source: [
+ {
+ type: GuideSectionTypes.JS,
+ code: splitButtonChangeDemoSource,
+ },
+ {
+ type: GuideSectionTypes.HTML,
+ code: splitButtonChangeDemoHtml,
+ },
+ ],
+ text: (
+
+ A special interaction between option-items and the Primary button can
+ be achieved through use of the `selectedIndex` and option-item’s
+ `onClick`. In this way, the user “selects” the primary
+ action from the options, then clicks the Primary button to execute
+ that action.
+
+ ),
+ props: { OuiSplitButton },
+ snippet: splitButtonChangeDemoSnippet,
+ demo: ,
+ },
+ ],
+};
diff --git a/src-docs/src/views/split_button/split_button_states.js b/src-docs/src/views/split_button/split_button_states.js
new file mode 100644
index 000000000..58b3a4864
--- /dev/null
+++ b/src-docs/src/views/split_button/split_button_states.js
@@ -0,0 +1,69 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import React from 'react';
+
+import {
+ OuiSplitButton,
+ OuiFlexGroup,
+ OuiFlexItem,
+} from '../../../../src/components/';
+import { flatten } from 'lodash';
+
+const options = [{ display: 'option' }];
+
+const colors = [
+ 'primary',
+ 'success',
+ 'warning',
+ 'danger',
+ 'text',
+ 'disabled',
+ 'ghost',
+];
+const fills = [false, true];
+const smalls = [undefined, 's'];
+
+const Name = ({ color, fill, small }) => {
+ if (fill && small) return 'Filled and Small';
+ if (fill) return 'Filled';
+ if (small) return 'Small';
+
+ return color;
+};
+
+const iterations = flatten(fills.map((f) => smalls.map((s) => [f, s])));
+
+const button = (groupColor, fill, size) => {
+ const disabled = groupColor === 'disabled' || groupColor === 'ghost';
+ const color = disabled ? 'text' : groupColor;
+
+ return (
+
+
+
+
+
+ );
+};
+
+const colorGroup = (color) => {
+ const buttons = iterations.map(([fill, size]) => button(color, fill, size));
+
+ return (
+
+ {buttons}
+
+ );
+};
+
+const colorGroups = colors.map((color) => colorGroup(color));
+
+export default () => {colorGroups}
;
diff --git a/src/components/index.js b/src/components/index.js
index c1112b570..80ff0c43c 100644
--- a/src/components/index.js
+++ b/src/components/index.js
@@ -1,31 +1,6 @@
/*
+ * Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
- *
- * The OpenSearch Contributors require contributions made to
- * this file be licensed under the Apache-2.0 license or a
- * compatible open source license.
- *
- * Modifications Copyright OpenSearch Contributors. See
- * GitHub history for details.
- */
-
-/*
- * Licensed to Elasticsearch B.V. under one or more contributor
- * license agreements. See the NOTICE file distributed with
- * this work for additional information regarding copyright
- * ownership. Elasticsearch B.V. licenses this file to you under
- * the Apache License, Version 2.0 (the "License"); you may
- * not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied. See the License for the
- * specific language governing permissions and limitations
- * under the License.
*/
export { OuiAccordion } from './accordion';
@@ -315,6 +290,8 @@ export { OuiSideNav } from './side_nav';
export { OuiSpacer } from './spacer';
+export { OuiSplitButton } from './split_button';
+
export { OuiStat } from './stat';
export { OuiStep, OuiSteps, OuiSubSteps, OuiStepsHorizontal } from './steps';
diff --git a/src/components/index.scss b/src/components/index.scss
index f008f44fd..c4ae8bf06 100644
--- a/src/components/index.scss
+++ b/src/components/index.scss
@@ -1,12 +1,6 @@
-/*!
+/*
+ * Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
- *
- * The OpenSearch Contributors require contributions made to
- * this file be licensed under the Apache-2.0 license or a
- * compatible open source license.
- *
- * Modifications Copyright OpenSearch Contributors. See
- * GitHub history for details.
*/
// Components
@@ -70,6 +64,7 @@
@import 'spacer/index';
@import 'search_bar/index';
@import 'selectable/index';
+@import 'split_button/index';
@import 'stat/index';
@import 'steps/index';
@import 'suggest/index';
diff --git a/src/components/split_button/__snapshots__/split_button.test.tsx.snap b/src/components/split_button/__snapshots__/split_button.test.tsx.snap
new file mode 100644
index 000000000..3ef4d1321
--- /dev/null
+++ b/src/components/split_button/__snapshots__/split_button.test.tsx.snap
@@ -0,0 +1,5377 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`OuiSplitButton is rendered 1`] = `
+
+`;
+
+exports[`OuiSplitButton onClick events selection list is opened on drop-down button click 1`] = `
+
+
+ Test
+
+ }
+ className="ouiSplitButton"
+ closePopover={[Function]}
+ display="inlineBlock"
+ hasArrow={false}
+ isOpen={true}
+ ownFocus={false}
+ panelPaddingSize="none"
+ panelRef={[Function]}
+ >
+
+
+
+
+
+
+
+
+
+
+
+ Test
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ You are in a selector of 2 items and must select a single option. Use the up and down keys to navigate or escape to close.
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+ onActivation={[Function]}
+ onDeactivation={[Function]}
+ persistentFocus={false}
+ returnFocus={[Function]}
+ shards={Array []}
+ sideCar={
+ Object {
+ "assignMedium": [Function],
+ "assignSyncMedium": [Function],
+ "options": Object {
+ "async": true,
+ "ssr": false,
+ },
+ "read": [Function],
+ "useMedium": [Function],
+ }
+ }
+ >
+
+
+
+ }
+ onActivation={[Function]}
+ onDeactivation={[Function]}
+ persistentFocus={false}
+ returnFocus={[Function]}
+ shards={Array []}
+ sideCar={
+ Object {
+ "assignMedium": [Function],
+ "assignSyncMedium": [Function],
+ "options": Object {
+ "async": true,
+ "ssr": false,
+ },
+ "read": [Function],
+ "useMedium": [Function],
+ }
+ }
+ >
+
+
+
+ }
+ onActivation={[Function]}
+ onDeactivation={[Function]}
+ persistentFocus={false}
+ returnFocus={[Function]}
+ shards={Array []}
+ >
+
+
+
+ }
+ onActivation={[Function]}
+ onDeactivation={[Function]}
+ persistentFocus={false}
+ returnFocus={[Function]}
+ shards={Array []}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`OuiSplitButton options rendering option with href renders link 1`] = `
+
+
+ Test
+
+ }
+ className="ouiSplitButton"
+ closePopover={[Function]}
+ display="inlineBlock"
+ hasArrow={false}
+ isOpen={true}
+ ownFocus={false}
+ panelPaddingSize="none"
+ panelRef={[Function]}
+ >
+
+
+
+
+
+
+
+
+
+
+
+ Test
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ You are in a selector of 1 items and must select a single option. Use the up and down keys to navigate or escape to close.
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+ onActivation={[Function]}
+ onDeactivation={[Function]}
+ persistentFocus={false}
+ returnFocus={[Function]}
+ shards={Array []}
+ sideCar={
+ Object {
+ "assignMedium": [Function],
+ "assignSyncMedium": [Function],
+ "options": Object {
+ "async": true,
+ "ssr": false,
+ },
+ "read": [Function],
+ "useMedium": [Function],
+ }
+ }
+ >
+
+
+
+ }
+ onActivation={[Function]}
+ onDeactivation={[Function]}
+ persistentFocus={false}
+ returnFocus={[Function]}
+ shards={Array []}
+ sideCar={
+ Object {
+ "assignMedium": [Function],
+ "assignSyncMedium": [Function],
+ "options": Object {
+ "async": true,
+ "ssr": false,
+ },
+ "read": [Function],
+ "useMedium": [Function],
+ }
+ }
+ >
+
+
+
+ }
+ onActivation={[Function]}
+ onDeactivation={[Function]}
+ persistentFocus={false}
+ returnFocus={[Function]}
+ shards={Array []}
+ >
+
+
+
+ }
+ onActivation={[Function]}
+ onDeactivation={[Function]}
+ persistentFocus={false}
+ returnFocus={[Function]}
+ shards={Array []}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`OuiSplitButton options rendering options are rendered when select is open 1`] = `
+
+
+ Test
+
+ }
+ className="ouiSplitButton"
+ closePopover={[Function]}
+ display="inlineBlock"
+ hasArrow={false}
+ isOpen={true}
+ ownFocus={false}
+ panelPaddingSize="none"
+ panelRef={[Function]}
+ >
+
+
+
+
+
+
+
+
+
+
+
+ Test
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ You are in a selector of 2 items and must select a single option. Use the up and down keys to navigate or escape to close.
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+ onActivation={[Function]}
+ onDeactivation={[Function]}
+ persistentFocus={false}
+ returnFocus={[Function]}
+ shards={Array []}
+ sideCar={
+ Object {
+ "assignMedium": [Function],
+ "assignSyncMedium": [Function],
+ "options": Object {
+ "async": true,
+ "ssr": false,
+ },
+ "read": [Function],
+ "useMedium": [Function],
+ }
+ }
+ >
+
+
+
+ }
+ onActivation={[Function]}
+ onDeactivation={[Function]}
+ persistentFocus={false}
+ returnFocus={[Function]}
+ shards={Array []}
+ sideCar={
+ Object {
+ "assignMedium": [Function],
+ "assignSyncMedium": [Function],
+ "options": Object {
+ "async": true,
+ "ssr": false,
+ },
+ "read": [Function],
+ "useMedium": [Function],
+ }
+ }
+ >
+
+
+
+ }
+ onActivation={[Function]}
+ onDeactivation={[Function]}
+ persistentFocus={false}
+ returnFocus={[Function]}
+ shards={Array []}
+ >
+
+
+
+ }
+ onActivation={[Function]}
+ onDeactivation={[Function]}
+ persistentFocus={false}
+ returnFocus={[Function]}
+ shards={Array []}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`OuiSplitButton options rendering selectedItem 0 is rendered 1`] = `
+
+
+ Test
+
+ }
+ className="ouiSplitButton"
+ closePopover={[Function]}
+ display="inlineBlock"
+ hasArrow={false}
+ isOpen={true}
+ ownFocus={false}
+ panelPaddingSize="none"
+ panelRef={[Function]}
+ >
+
+
+
+
+
+
+
+
+
+
+
+ Test
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ You are in a selector of 2 items and must select a single option. Use the up and down keys to navigate or escape to close.
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+ onActivation={[Function]}
+ onDeactivation={[Function]}
+ persistentFocus={false}
+ returnFocus={[Function]}
+ shards={Array []}
+ sideCar={
+ Object {
+ "assignMedium": [Function],
+ "assignSyncMedium": [Function],
+ "options": Object {
+ "async": true,
+ "ssr": false,
+ },
+ "read": [Function],
+ "useMedium": [Function],
+ }
+ }
+ >
+
+
+
+ }
+ onActivation={[Function]}
+ onDeactivation={[Function]}
+ persistentFocus={false}
+ returnFocus={[Function]}
+ shards={Array []}
+ sideCar={
+ Object {
+ "assignMedium": [Function],
+ "assignSyncMedium": [Function],
+ "options": Object {
+ "async": true,
+ "ssr": false,
+ },
+ "read": [Function],
+ "useMedium": [Function],
+ }
+ }
+ >
+
+
+
+ }
+ onActivation={[Function]}
+ onDeactivation={[Function]}
+ persistentFocus={false}
+ returnFocus={[Function]}
+ shards={Array []}
+ >
+
+
+
+ }
+ onActivation={[Function]}
+ onDeactivation={[Function]}
+ persistentFocus={false}
+ returnFocus={[Function]}
+ shards={Array []}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`OuiSplitButton options rendering selectedItem last is rendered 1`] = `
+
+
+ Test
+
+ }
+ className="ouiSplitButton"
+ closePopover={[Function]}
+ display="inlineBlock"
+ hasArrow={false}
+ isOpen={true}
+ ownFocus={false}
+ panelPaddingSize="none"
+ panelRef={[Function]}
+ >
+
+
+
+
+
+
+
+
+
+
+
+ Test
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ You are in a selector of 3 items and must select a single option. Use the up and down keys to navigate or escape to close.
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+ onActivation={[Function]}
+ onDeactivation={[Function]}
+ persistentFocus={false}
+ returnFocus={[Function]}
+ shards={Array []}
+ sideCar={
+ Object {
+ "assignMedium": [Function],
+ "assignSyncMedium": [Function],
+ "options": Object {
+ "async": true,
+ "ssr": false,
+ },
+ "read": [Function],
+ "useMedium": [Function],
+ }
+ }
+ >
+
+
+
+ }
+ onActivation={[Function]}
+ onDeactivation={[Function]}
+ persistentFocus={false}
+ returnFocus={[Function]}
+ shards={Array []}
+ sideCar={
+ Object {
+ "assignMedium": [Function],
+ "assignSyncMedium": [Function],
+ "options": Object {
+ "async": true,
+ "ssr": false,
+ },
+ "read": [Function],
+ "useMedium": [Function],
+ }
+ }
+ >
+
+
+
+ }
+ onActivation={[Function]}
+ onDeactivation={[Function]}
+ persistentFocus={false}
+ returnFocus={[Function]}
+ shards={Array []}
+ >
+
+
+
+ }
+ onActivation={[Function]}
+ onDeactivation={[Function]}
+ persistentFocus={false}
+ returnFocus={[Function]}
+ shards={Array []}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`OuiSplitButton props fullWidth is rendered 1`] = `
+
+`;
diff --git a/src/components/split_button/__snapshots__/split_button_control.test.tsx.snap b/src/components/split_button/__snapshots__/split_button_control.test.tsx.snap
new file mode 100644
index 000000000..46cd8059a
--- /dev/null
+++ b/src/components/split_button/__snapshots__/split_button_control.test.tsx.snap
@@ -0,0 +1,106 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`OuiSplitButtonControl is rendered 1`] = `
+
+
+
+
+ Test
+
+
+
+
+
+
+
+`;
+
+exports[`OuiSplitButtonControl props fullWidth is rendered 1`] = `
+
+
+
+
+ Test
+
+
+
+
+
+
+
+`;
+
+exports[`OuiSplitButtonControl props isLoading is rendered 1`] = `
+
+
+
+
+ Test
+
+
+
+
+
+
+
+`;
diff --git a/src/components/split_button/_index.scss b/src/components/split_button/_index.scss
new file mode 100644
index 000000000..05c66a515
--- /dev/null
+++ b/src/components/split_button/_index.scss
@@ -0,0 +1,7 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+@import 'split_button';
+@import 'split_button_control';
diff --git a/src/components/split_button/_split_button.scss b/src/components/split_button/_split_button.scss
new file mode 100644
index 000000000..52ceed59f
--- /dev/null
+++ b/src/components/split_button/_split_button.scss
@@ -0,0 +1,21 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+.ouiSplitButton__listbox {
+ @include ouiScrollBar;
+ max-height: 300px;
+ overflow: hidden;
+ overflow-y: auto;
+}
+
+.ouiSplitButton__item {
+ @include ouiFontSizeS;
+ @include ouiInteractiveStates;
+ padding: $ouiSizeS;
+}
+
+.ouiSplitButton__item--hasDividers:not(:last-of-type) {
+ border-bottom: $ouiBorderThin;
+}
diff --git a/src/components/split_button/_split_button_control.scss b/src/components/split_button/_split_button_control.scss
new file mode 100644
index 000000000..97f447a69
--- /dev/null
+++ b/src/components/split_button/_split_button_control.scss
@@ -0,0 +1,165 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+// sass-lint:disable no-vendor-prefixes no-qualifying-elements nesting-depth
+
+$splitButtonSeparatorColor: shade($ouiColorPrimary, 50%);
+
+.ouiSplitButton {
+
+ //width: 100% !important;
+
+ // Remove individual button style
+ //@eslint
+ button.ouiSplitButtonControl--primary {
+ // reset all but right border
+ border-top-width: 0px;
+ border-left-width: 0px;
+ border-bottom-width: 0px;
+ border-right-width: $ouiBorderWidthThin;
+ border-top-right-radius: 0px;
+ border-top-left-radius: 0px;
+ border-bottom-right-radius: 0px;
+ border-bottom-left-radius: 0px;
+
+ &:hover,
+ &:active,
+ &:focus {
+ -webkit-transform: none;
+ transform: none;
+ }
+ }
+
+ button.ouiSplitButtonControl--dropdown {
+ border-width: 0px;
+ border-radius: 0px;
+
+ &:hover,
+ &:active,
+ &:focus {
+ -webkit-transform: none;
+ transform: none;
+ }
+ }
+
+ .ouiSplitButtonControl {
+ border: $ouiBorderWidthThick solid $ouiBorderColor;
+ border-radius: $ouiBorderRadius;
+
+ // Animate wrapper element only when primary button activated
+ &:has(.ouiSplitButtonControl--primary:hover:not([class*='isDisabled'])) {
+ -webkit-transform: translateY(-1px);
+ transform: translateY(-1px);
+ }
+
+ &:has(.ouiSplitButtonControl--primary:active:not([class*='isDisabled'])) {
+ -webkit-transform: translateY(1px);
+ transform: translateY(1px);
+ }
+
+ &:has(.ouiSplitButtonControl--primary:focus:not([class*='isDisabled'])) {
+ animation: ouiButtonActive $ouiAnimSpeedNormal $ouiAnimSlightBounce;
+ }
+
+ //.ouiSplitButtonControl--primary {
+ // border-right: $ouiBorderWidthThin solid $splitButtonSeparatorColor;
+ //
+ //}
+
+ }
+
+ // Create button modifiers based upon the map.
+ @each $name, $color in $ouiButtonTypes {
+ .ouiSplitButtonHairline--#{$name} {
+ &:not([class*='isDisabled']) {
+
+ border-right-color: transparentize($color, .8);
+ }
+
+
+ &.ouiSplitButtonHairline--filled {
+ &:not([class*='isDisabled']) {
+ border-right-color: darken($color, 10%);
+ }
+ }
+ }
+ }
+
+
+ // Create button modifiers based upon the map.
+ @each $name, $color in $ouiButtonTypes {
+ .ouiSplitButtonColor--#{$name} {
+ @if ($name == 'ghost') {
+ // Ghost is unique and ALWAYS sits against a dark background.
+ color: $color;
+ } @else if ($name == 'text') {
+ // The default color is lighter than the normal text color, make the it the text color
+ color: $ouiTextColor;
+ } @else {
+ // Other colors need to check their contrast against the page background color.
+ color: makeHighContrastColor($color, $ouiPageBackgroundColor);
+ }
+
+ border-color: $color;
+
+ &.ouiSplitButtonColor--fill {
+ background-color: $color;
+ border-color: $color;
+
+ // The function makes that hexes safe for theming
+ $fillTextColor: chooseLightOrDarkText($color, $ouiColorGhost, $ouiColorInk);
+
+ color: $fillTextColor;
+
+ &:not([class*='isDisabled']) {
+ &:hover,
+ &:focus,
+ &:focus-within {
+ background-color: darken($color, 5%);
+ border-color: darken($color, 5%);
+ }
+ }
+ }
+
+ &:not([class*='isDisabled']) {
+ $shadowColor: $ouiShadowColor;
+ @if ($name == 'ghost') {
+ $shadowColor: $ouiColorInk;
+ } @else if (lightness($ouiTextColor) < 50) {
+ // Only if this is the light theme do we use the button variant color to colorize the shadow
+ $shadowColor: desaturate($color, 60%);
+ }
+
+ @include ouiSlightShadow($shadowColor);
+
+ &:hover,
+ &:focus,
+ &:focus-within {
+ @include ouiSlightShadowHover($shadowColor);
+ background-color: transparentize($color, .9);
+ }
+ }
+ }
+ }
+
+ // Fix ghost/disabled look specifically
+ .ouiSplitButtonColor.ouiSplitButtonColor-isDisabled.ouiSplitButtonColor--ghost {
+ &,
+ &:hover,
+ &:focus,
+ &:focus-within {
+ @include ouiSlightShadow($ouiColorInk);
+ color: $ouiButtonColorGhostDisabled;
+ border-color: $ouiButtonColorGhostDisabled;
+ }
+
+ &.ouiSplitButtonColor--fill {
+ background-color: $ouiButtonColorGhostDisabled;
+ color: makeHighContrastColor($ouiButtonColorGhostDisabled, $ouiButtonColorGhostDisabled, 2);
+ }
+ }
+
+
+}
diff --git a/src/components/split_button/index.ts b/src/components/split_button/index.ts
new file mode 100644
index 000000000..1458ddc0f
--- /dev/null
+++ b/src/components/split_button/index.ts
@@ -0,0 +1,11 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+export { OuiSplitButton, OuiSplitButtonProps } from './split_button';
+export {
+ OuiSplitButtonControl,
+ OuiSplitButtonControlProps,
+ OuiSplitButtonColor,
+} from './split_button_control';
diff --git a/src/components/split_button/split_button.test.tsx b/src/components/split_button/split_button.test.tsx
new file mode 100644
index 000000000..0e30a381d
--- /dev/null
+++ b/src/components/split_button/split_button.test.tsx
@@ -0,0 +1,347 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import React from 'react';
+import each from 'jest-each';
+
+import { ReactWrapper, mount, render } from 'enzyme';
+import { requiredProps } from '../../test';
+import { keys } from '../../services';
+import { OuiSplitButton, OuiSplitButtonOption } from './split_button';
+import { act } from 'react-dom/test-utils';
+
+jest.mock('../portal', () => ({
+ OuiPortal: ({ children }: any) => children,
+}));
+
+interface WaitForOptions {
+ interval?: number;
+ timeout?: number;
+ message?: string;
+}
+
+/**
+ * Iterate a callback until callback's expect() is pass.
+ * @param callback - fn to iterate until expect pass
+ * @param param1 - options : interval, timeout, message
+ * @returns void
+ */
+// Deprecate/delete after resolution of https://github.com/opensearch-project/oui/issues/1197
+const waitFor = (
+ callback: () => void,
+ { interval = 50, timeout = 1000, message = 'Timed out.' }: WaitForOptions = {}
+) =>
+ act(
+ () =>
+ new Promise((resolve, reject) => {
+ const startTime = Date.now();
+
+ const nextInterval = () => {
+ setTimeout(() => {
+ try {
+ callback();
+ resolve();
+ } catch (err) {
+ if (Date.now() - startTime > timeout) {
+ reject(new Error(message));
+ } else {
+ nextInterval();
+ }
+ }
+ }, interval);
+ };
+
+ nextInterval();
+ })
+ );
+
+/**
+ * use waitFor() until document.activeElement equals element found by selector
+ * @param component - target Enzyme wrapper
+ * @param selector - CSS selector for Enzyme find()
+ * @param options - options to pass to waitFor()
+ * @returns void
+ */
+const findByFocused = (
+ component: ReactWrapper,
+ selector: string,
+ options: WaitForOptions
+) =>
+ waitFor(() => {
+ component.update();
+ const expectedActive = component.find(selector);
+ expect(expectedActive.getDOMNode()).toEqual(document.activeElement);
+ }, options);
+
+const options: OuiSplitButtonOption[] = [
+ {
+ display: 'Option #1',
+ href: '#',
+ },
+ { display: 'Option #2' },
+];
+
+describe('OuiSplitButton', () => {
+ test('is rendered', () => {
+ const component = render(
+ Test
+ );
+
+ expect(component).toMatchSnapshot();
+ });
+
+ describe('props', () => {
+ test('fullWidth is rendered', () => {
+ const component = render(
+
+ Test
+
+ );
+
+ expect(component).toMatchSnapshot();
+ });
+ });
+
+ describe('options rendering', () => {
+ test('options are rendered when select is open', async () => {
+ const component = mount(
+
+ Test
+
+ );
+
+ component.update();
+ expect(component.find('button.ouiSplitButton__item')).toHaveLength(1);
+ expect(component.find('a.ouiSplitButton__item')).toHaveLength(1);
+ expect(component).toMatchSnapshot();
+ });
+
+ test('selectedItem 0 is rendered', async () => {
+ const component = mount(
+
+ Test
+
+ );
+
+ const selected = component.find('a[aria-selected="true"]');
+ expect(selected).toHaveLength(1);
+ expect(selected.text()).toEqual('Option #1');
+ expect(component).toMatchSnapshot();
+ });
+
+ test('selectedItem last is rendered', async () => {
+ const component = mount(
+
+ Test
+
+ );
+
+ const selected = component.find('button[aria-selected="true"]');
+ expect(selected).toHaveLength(1);
+ expect(selected.text()).toEqual('Option #3');
+ expect(component).toMatchSnapshot();
+ });
+
+ test('option with href renders link', async () => {
+ const component = mount(
+
+ Test
+
+ );
+
+ const selected = component.find('a[href="#test"]');
+ expect(selected).toHaveLength(1);
+ expect(component).toMatchSnapshot();
+ });
+ });
+
+ describe('onClick events', () => {
+ test('selection list is opened on drop-down button click', async () => {
+ const component = mount(
+
+ Test
+
+ );
+
+ expect(
+ component.find('OuiContextMenuItem.ouiSplitButton__item')
+ ).toHaveLength(0);
+
+ component
+ .find('button.ouiSplitButtonControl--dropdown')
+ .simulate('click');
+
+ component.update();
+
+ expect(
+ component.find('OuiContextMenuItem.ouiSplitButton__item')
+ ).toHaveLength(2);
+
+ expect(component).toMatchSnapshot();
+ });
+
+ test('onClick is called on Primary button click', async () => {
+ const onClick = jest.fn();
+ const component = mount(
+ Test
+ );
+
+ component.find('button.ouiSplitButtonControl--primary').simulate('click');
+
+ expect(onClick).toHaveBeenCalled();
+ });
+
+ test('onClick of option-item is called when an option is selected', async () => {
+ const onClickOption = jest.fn();
+ options[0].onClick = onClickOption;
+ const component = mount(
+
+ Test
+
+ );
+
+ const selected = component.find(
+ 'OuiContextMenuItem[aria-selected="false"]'
+ );
+ expect(selected).toHaveLength(1);
+ selected.at(0).simulate('click');
+
+ expect(onClickOption).toHaveBeenCalled();
+ });
+ });
+ describe('Option-list keyboard control', () => {
+ describe('key up-down on buttons opens options list', () => {
+ each([
+ { key: keys.ARROW_DOWN, button: 'primary' },
+ { key: keys.ARROW_UP, button: 'primary' },
+ { key: keys.ARROW_DOWN, button: 'dropdown' },
+ { key: keys.ARROW_UP, button: 'dropdown' },
+ ]).test('$key on $button button opens options', ({ key, button }) => {
+ const component = mount(
+
+ test
+
+ );
+
+ expect(
+ component.find('OuiContextMenuItem.ouiSplitButton__item')
+ ).toHaveLength(0);
+
+ component
+ .find(`button.ouiSplitButtonControl--${button}`)
+ .simulate('keydown', { key });
+
+ component.update();
+
+ expect(
+ component.find('OuiContextMenuItem.ouiSplitButton__item')
+ ).toHaveLength(2);
+ });
+ });
+ describe('key up-down on options list changes focus', () => {
+ const options = [
+ { display: 'option 1' },
+ { display: 'option 2' },
+ { display: 'option 3' },
+ ];
+
+ each([
+ {
+ desc: 'focus next',
+ startSelection: 1,
+ key: keys.ARROW_DOWN,
+ resultFocusSelection: 2,
+ },
+ {
+ desc: 'focus prev',
+ startSelection: 1,
+ key: keys.ARROW_UP,
+ resultFocusSelection: 0,
+ },
+ {
+ desc: 'cycle to top',
+ startSelection: 2,
+ key: keys.ARROW_DOWN,
+ resultFocusSelection: 0,
+ },
+ {
+ desc: 'cycle to bottom',
+ startSelection: 0,
+ key: keys.ARROW_UP,
+ resultFocusSelection: 2,
+ },
+ ]).test(
+ '$key on option list item $startSelection $desc',
+ async ({ startSelection, key, resultFocusSelection }) => {
+ const component = mount(
+
+ test
+
+ );
+
+ await findByFocused(
+ component,
+ `button#splitButtonItem_${startSelection}`,
+ { message: `Initial selected focus ${startSelection} not found.` }
+ );
+
+ const items = component.find(
+ 'OuiContextMenuItem.ouiSplitButton__item'
+ );
+ expect(items).toHaveLength(3);
+
+ items.at(startSelection).simulate('keydown', { key });
+
+ component.update();
+
+ await findByFocused(
+ component,
+ `button#splitButtonItem_${resultFocusSelection}`,
+ {
+ message: `Expected selected focus ${resultFocusSelection} not found.`,
+ }
+ );
+ }
+ );
+ });
+ test('key escape on options list closes list', async () => {
+ const component = mount(
+
+ test
+
+ );
+
+ await findByFocused(component, 'button#splitButtonItem_1', {
+ message: 'Initial selected focus 1 not found.',
+ });
+
+ const displayedItems = component.find(
+ 'OuiContextMenuItem.ouiSplitButton__item'
+ );
+ expect(displayedItems).toHaveLength(2);
+
+ displayedItems.at(1).simulate('keydown', { key: keys.ESCAPE });
+
+ await waitFor(
+ () => {
+ component.update();
+ const closedItems = component.find(
+ 'OuiContextMenuItem.ouiSplitButton__item'
+ );
+ expect(closedItems).toHaveLength(0);
+ },
+ { message: 'Expected options to not exist' }
+ );
+ });
+ });
+});
diff --git a/src/components/split_button/split_button.tsx b/src/components/split_button/split_button.tsx
new file mode 100644
index 000000000..860c52dc1
--- /dev/null
+++ b/src/components/split_button/split_button.tsx
@@ -0,0 +1,336 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import React, {
+ ReactNode,
+ useCallback,
+ useEffect,
+ useMemo,
+ useState,
+} from 'react';
+import classNames from 'classnames';
+
+import { CommonProps } from '../common';
+
+import { OuiScreenReaderOnly } from '../accessibility';
+import {
+ OuiSplitButtonControl,
+ OuiSplitButtonControlProps,
+} from './split_button_control';
+import { OuiPopover } from '../popover';
+import { OuiContextMenuItem } from '../context_menu';
+import { cascadingMenuKeys, keys } from '../../services';
+import { OuiI18n } from '../i18n';
+import { OuiButtonProps } from '../button';
+import { OuiText, OuiTextProps } from '../text';
+import { OuiFocusTrap } from '../focus_trap';
+import { tabbable } from 'tabbable';
+
+enum ShiftDirection {
+ BACK = 'back',
+ FORWARD = 'forward',
+}
+
+export interface OuiSplitButtonOption {
+ display: string | ReactNode;
+ onClick?: () => void;
+ href?: string;
+ target?: string;
+}
+
+export type OuiSplitButtonProps = CommonProps &
+ Omit<
+ OuiSplitButtonControlProps,
+ 'onChange' | 'onDropdownClick' | 'options' | 'value'
+ > & {
+ /**
+ * Pass an array of options
+ */
+ options?: OuiSplitButtonOption[];
+
+ /**
+ * Classes for the context menu item
+ */
+ itemClassName?: string;
+
+ /**
+ * optional index of options item to mark with checkmark
+ */
+ selectedIndex?: number;
+
+ /**
+ * Change to `true` if you want horizontal lines between options.
+ * This is best used when options are multi-line.
+ */
+ hasDividers?: boolean;
+
+ /**
+ * Applied to the outermost wrapper (popover)
+ */
+ popoverClassName?: string;
+
+ /**
+ * Controls whether the options are shown upon initial render. Default: false
+ */
+ initiallyOpen?: boolean;
+
+ /**
+ * Optional additional props to send to Primary Button
+ */
+ buttonProps?: OuiButtonProps;
+
+ /**
+ * Optional additional props to send to Dropdown Button
+ */
+ dropdownProps?: OuiButtonProps;
+
+ /**
+ * Optional additional props to send to each Option Item, when
+ * it is string, rendered with OuiText wrapper
+ */
+ optionProps?: OuiTextProps;
+ };
+
+export const OuiSplitButton = ({
+ color = 'primary',
+ fullWidth = false,
+ disabled,
+ options = [],
+ selectedIndex,
+ initiallyOpen = false,
+ hasDividers,
+ itemClassName,
+ onClick,
+ className,
+ popoverClassName,
+ children,
+ dropdownProps,
+ optionProps,
+ buttonProps,
+ ...rest
+}: OuiSplitButtonProps) => {
+ const itemNodes: Array = useMemo(() => [], []);
+ const [isOpen, setIsOpen] = useState(!!initiallyOpen);
+ const [panelEl, setPanelEl] = useState(null);
+ const panelRef = (node: HTMLElement | null) => setPanelEl(node);
+
+ const onKeyDown = (event: React.KeyboardEvent) => {
+ if (panelEl && event.key === cascadingMenuKeys.TAB) {
+ const tabbableItems = tabbable(panelEl).filter((el) => {
+ return (
+ Array.from(el.attributes)
+ .map((el) => el.name)
+ .indexOf('data-focus-guard') < 0
+ );
+ });
+ if (
+ tabbableItems.length &&
+ tabbableItems[tabbableItems.length - 1] === document.activeElement
+ ) {
+ setIsOpen(false);
+ }
+ }
+ };
+
+ const focusItemAt = useCallback(
+ (index: number) => {
+ const targetElement = itemNodes[index];
+ if (targetElement != null) {
+ targetElement.focus();
+
+ return targetElement.matches(':focus');
+ }
+ },
+ [itemNodes]
+ );
+
+ const focusSelected = useCallback(() => {
+ requestAnimationFrame(() => {
+ const hasFocus = focusItemAt(selectedIndex || 0);
+ if (!hasFocus) {
+ focusSelected();
+ }
+ });
+ }, [selectedIndex, focusItemAt]);
+
+ useEffect(() => {
+ isOpen && requestAnimationFrame(focusSelected);
+ }, [isOpen, focusSelected]);
+
+ const onSelectKeyDown = (event: React.KeyboardEvent) => {
+ if (event.key === keys.ARROW_UP || event.key === keys.ARROW_DOWN) {
+ event.preventDefault();
+ event.stopPropagation();
+ setIsOpen(true);
+ }
+ };
+
+ const shiftFocus = (direction: ShiftDirection) => {
+ const currentIndex = itemNodes.indexOf(
+ document.activeElement as HTMLButtonElement
+ );
+
+ setIsOpen(true);
+
+ if (currentIndex === -1) {
+ // somehow the select options has lost focus
+ focusItemAt(0);
+ } else {
+ if (direction === ShiftDirection.BACK) {
+ focusItemAt(
+ currentIndex === 0 ? itemNodes.length - 1 : currentIndex - 1
+ );
+ } else {
+ focusItemAt(
+ currentIndex === itemNodes.length - 1 ? 0 : currentIndex + 1
+ );
+ }
+ }
+ };
+
+ const onItemKeyDown = (event: React.KeyboardEvent) => {
+ if (event.key === keys.ESCAPE) {
+ // close the popover and prevent ancestors from handling
+ event.preventDefault();
+ event.stopPropagation();
+ setIsOpen(false);
+ } else if (event.key === keys.TAB) {
+ event.preventDefault();
+ event.stopPropagation();
+ shiftFocus(ShiftDirection.FORWARD);
+ } else if (event.key === keys.TAB && event.shiftKey) {
+ event.preventDefault();
+ event.stopPropagation();
+ shiftFocus(ShiftDirection.BACK);
+ } else if (event.key === keys.ARROW_UP) {
+ event.preventDefault();
+ event.stopPropagation();
+ shiftFocus(ShiftDirection.BACK);
+ } else if (event.key === keys.ARROW_DOWN) {
+ event.preventDefault();
+ event.stopPropagation();
+ shiftFocus(ShiftDirection.FORWARD);
+ }
+ };
+
+ const popoverClasses = classNames('ouiSplitButton', popoverClassName);
+
+ const buttonClasses = classNames(
+ {
+ 'ouiSplitButton--isOpen__button': isOpen,
+ },
+ className
+ );
+
+ const itemClasses = classNames(
+ 'ouiSplitButton__item',
+ {
+ 'ouiSplitButton__item--hasDividers': hasDividers,
+ },
+ itemClassName
+ );
+
+ const onPrimaryClick = () => {
+ onClick?.();
+ setIsOpen(false);
+ };
+
+ const button = (
+ setIsOpen(!isOpen)}
+ onClick={onPrimaryClick}
+ onKeyDown={onSelectKeyDown}
+ className={buttonClasses}
+ fullWidth={fullWidth}
+ dropdownProps={dropdownProps}
+ buttonProps={buttonProps}
+ disabled={disabled}
+ {...rest}>
+ {children}
+
+ );
+
+ const itemIcon = (index: number) => {
+ if (selectedIndex === undefined) return;
+
+ if (selectedIndex === index) return 'check';
+
+ return 'empty';
+ };
+
+ const items = options.map((option, index) => {
+ const isSelected = selectedIndex === index;
+
+ const content =
+ typeof option.display === 'string' ? (
+
+ {option.display}
+
+ ) : (
+ option.display
+ );
+
+ const itemOnClick = () => {
+ setIsOpen(false);
+ option.onClick?.();
+ };
+
+ return (
+ (itemNodes[index] = node)}
+ role="option"
+ id={`splitButtonItem_${index}`}
+ aria-selected={isSelected ? 'true' : 'false'}>
+ {content}
+
+ );
+ });
+
+ // return SplitButton
;
+ return (
+ setIsOpen(false)}
+ panelPaddingSize="none">
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/src/components/split_button/split_button_control.test.tsx b/src/components/split_button/split_button_control.test.tsx
new file mode 100644
index 000000000..411cad513
--- /dev/null
+++ b/src/components/split_button/split_button_control.test.tsx
@@ -0,0 +1,38 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import React from 'react';
+import { render } from 'enzyme';
+import { requiredProps } from '../../test';
+
+import { OuiSplitButtonControl } from './split_button_control';
+
+describe('OuiSplitButtonControl', () => {
+ test('is rendered', () => {
+ const component = render(
+ Test
+ );
+
+ expect(component).toMatchSnapshot();
+ });
+
+ describe('props', () => {
+ test('fullWidth is rendered', () => {
+ const component = render(
+ Test
+ );
+
+ expect(component).toMatchSnapshot();
+ });
+
+ test('isLoading is rendered', () => {
+ const component = render(
+ Test
+ );
+
+ expect(component).toMatchSnapshot();
+ });
+ });
+});
diff --git a/src/components/split_button/split_button_control.tsx b/src/components/split_button/split_button_control.tsx
new file mode 100644
index 000000000..b59b62797
--- /dev/null
+++ b/src/components/split_button/split_button_control.tsx
@@ -0,0 +1,162 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import React, {
+ FunctionComponent,
+ ButtonHTMLAttributes,
+ ReactNode,
+} from 'react';
+
+import { CommonProps, ExclusiveUnion } from '../common';
+
+import {
+ ButtonColor,
+ ButtonSize,
+ OuiButton,
+ OuiButtonIcon,
+ OuiButtonIconColor,
+ OuiButtonProps,
+} from '../button';
+import {
+ OuiButtonPropsForAnchor,
+ OuiButtonPropsForButton,
+ colorToClassNameMap,
+} from '../button/button';
+import classNames from 'classnames';
+
+// this intersection still does not satisfy OuiButtonIconColor
+// https://github.com/opensearch-project/oui/issues/1196
+export type OuiSplitButtonColor = OuiButtonIconColor & ButtonColor;
+
+type OuiSplitButtonActionProps = ExclusiveUnion<
+ OuiButtonPropsForAnchor,
+ OuiButtonPropsForButton
+>;
+
+export interface OuiSplitButtonControlProps
+ extends CommonProps,
+ Omit, 'color'> {
+ fullWidth?: boolean;
+ isLoading?: boolean;
+
+ fill?: boolean;
+
+ /**
+ * Color of buttons and options
+ */
+ color?: OuiSplitButtonColor;
+
+ /**
+ * Use size `s` in confined spaces
+ */
+ size?: ButtonSize;
+
+ /**
+ * Click handler of Primary button
+ */
+ onClick?: () => void;
+
+ /**
+ * Click handler for drop-down button -- used by SplitButton to control
+ * OuiPopover
+ */
+ onDropdownClick?: () => void;
+
+ /**
+ * Handle key-events for dropdown control
+ */
+ onKeyDown?: (event: React.KeyboardEvent) => void;
+
+ /**
+ * Optional additional props to send to Primary Button
+ */
+ buttonProps?: OuiButtonProps;
+
+ /**
+ * Optional additional props to send to Dropdown Button
+ */
+ dropdownProps?: OuiButtonProps;
+
+ /**
+ * Content of Primary (left-side) button
+ */
+ children: ReactNode;
+}
+
+export const OuiSplitButtonControl: FunctionComponent<
+ OuiSplitButtonControlProps & OuiSplitButtonActionProps
+> = ({
+ fill,
+ size,
+ color = 'primary',
+ disabled = false,
+ children,
+ fullWidth,
+ onClick,
+ href,
+ target,
+ rel,
+ onDropdownClick,
+ onKeyDown: onSelectKeydown,
+ buttonProps,
+ dropdownProps,
+}) => {
+ const iconDisplay = fill ? 'fill' : 'base';
+
+ const className = classNames(
+ 'ouiSplitButtonControl',
+ color && `ouiSplitButtonColor${colorToClassNameMap[color]}`,
+ disabled && 'ouiSplitButtonColor-isDisabled',
+ fill && 'ouiSplitButtonColor--filled'
+ );
+
+ const primaryButtonClasses = classNames(
+ 'ouiSplitButtonControl',
+ 'ouiSplitButtonControl--primary',
+ color && `ouiSplitButtonHairline${colorToClassNameMap[color]}`,
+ disabled && 'ouiSplitButtonHairline--isDisabled',
+ fill && 'ouiSplitButtonHairline--filled'
+ );
+
+ const actionProps = {
+ href,
+ target,
+ rel,
+ onClick,
+ };
+ return (
+
+
+ {children}
+
+
+
+ );
+};