From 54bb752289f364be8a6f378374661a353b826bf9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 11 Jan 2024 11:09:39 +0000 Subject: [PATCH] Split Button - initial concept. Includes Docs update. (#1193) * Split Button - initial concept. Includes Docs update. Signed-off-by: Peter Fitzgibbons * WIP SplitButton w/ Popover wrapper and dropdown list Signed-off-by: Peter Fitzgibbons * Style primary control w/ hairline separator Signed-off-by: Peter Fitzgibbons * Hairline Styling -- more Signed-off-by: Peter Fitzgibbons * Setup SplitButton doc Basic/States Signed-off-by: Peter Fitzgibbons * Convert SplitButton to React FC Signed-off-by: Peter Fitzgibbons * Set correct SplitButton doc Basic,States Signed-off-by: Peter Fitzgibbons * SplitButton tests and updated docs Signed-off-by: Peter Fitzgibbons * Cleanup test and doc lint Signed-off-by: Peter Fitzgibbons * Changelog SplitButton Signed-off-by: Peter Fitzgibbons * Automate CSS Y-axis with buttons in tandem. Signed-off-by: Peter Fitzgibbons * SplitButton handle keyboard control. Unify CSS automation. Allow onClick/href for each option item. Signed-off-by: Peter Fitzgibbons * SplitButton popover correct keyboard interaction Signed-off-by: Peter Fitzgibbons * Border and hairline colors corrected. Popover styling and keyboard control corrected. Signed-off-by: Peter Fitzgibbons * many many nits. code cleanup. propagate optional props into their target components recover Change Demo doc. Signed-off-by: Peter Fitzgibbons * lint fixes and test cleanup. Signed-off-by: Peter Fitzgibbons * Copyright Headers Signed-off-by: Peter Fitzgibbons --------- Signed-off-by: Peter Fitzgibbons (cherry picked from commit eac077ecf23d992fe9972882fa898b9ef4e28986) Signed-off-by: github-actions[bot] # Conflicts: # CHANGELOG.md --- src-docs/src/routes.js | 11 +- .../views/split_button/split_button_basic.js | 27 + .../split_button/split_button_change_demo.js | 58 + .../split_button/split_button_complex.js | 49 + .../split_button/split_button_example.js | 255 + .../views/split_button/split_button_states.js | 69 + src/components/index.js | 29 +- src/components/index.scss | 11 +- .../__snapshots__/split_button.test.tsx.snap | 5377 +++++++++++++++++ .../split_button_control.test.tsx.snap | 106 + src/components/split_button/_index.scss | 7 + .../split_button/_split_button.scss | 21 + .../split_button/_split_button_control.scss | 165 + src/components/split_button/index.ts | 11 + .../split_button/split_button.test.tsx | 347 ++ src/components/split_button/split_button.tsx | 336 + .../split_button_control.test.tsx | 38 + .../split_button/split_button_control.tsx | 162 + 18 files changed, 7038 insertions(+), 41 deletions(-) create mode 100644 src-docs/src/views/split_button/split_button_basic.js create mode 100644 src-docs/src/views/split_button/split_button_change_demo.js create mode 100644 src-docs/src/views/split_button/split_button_complex.js create mode 100644 src-docs/src/views/split_button/split_button_example.js create mode 100644 src-docs/src/views/split_button/split_button_states.js create mode 100644 src/components/split_button/__snapshots__/split_button.test.tsx.snap create mode 100644 src/components/split_button/__snapshots__/split_button_control.test.tsx.snap create mode 100644 src/components/split_button/_index.scss create mode 100644 src/components/split_button/_split_button.scss create mode 100644 src/components/split_button/_split_button_control.scss create mode 100644 src/components/split_button/index.ts create mode 100644 src/components/split_button/split_button.test.tsx create mode 100644 src/components/split_button/split_button.tsx create mode 100644 src/components/split_button/split_button_control.test.tsx create mode 100644 src/components/split_button/split_button_control.tsx diff --git a/src-docs/src/routes.js b/src-docs/src/routes.js index 2ef7c4f5a2..2f025edcc6 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 0000000000..21f6fc4c7a --- /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 0000000000..2c936d9f93 --- /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 0000000000..356b7cf1f0 --- /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 0000000000..69f0d89cd6 --- /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 0000000000..58b3a4864b --- /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 c1112b5701..80ff0c43cd 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 f008f44fdd..c4ae8bf060 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 0000000000..3ef4d1321e --- /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]} + > + +
+
+ +
+ + + + + + + + +
+
+
+ + + + + +
+
+ +
+ +
+
+ +
+ +

+ + 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. + +

+
+ + + + +
+
+ +
+
+ + + + +
+
+ Option #1 +
+
+
+
+
+ +
+
+
+ } + 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], + } + } + > + +
+
+ + + + +
+
+ Option #1 +
+
+
+
+
+ +
+
+
+ } + 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], + } + } + > + +
+
+ + + + +
+
+ Option #1 +
+
+
+
+
+ +
+
+
+ } + onActivation={[Function]} + onDeactivation={[Function]} + persistentFocus={false} + returnFocus={[Function]} + shards={Array []} + > + +
+
+ + + + +
+
+ Option #1 +
+
+
+
+
+ +
+
+
+ } + onActivation={[Function]} + onDeactivation={[Function]} + persistentFocus={false} + returnFocus={[Function]} + shards={Array []} + /> + + + + +
+
+
+ + + + + + + + +
+ +
+ Option #1 +
+
+
+
+
+
+
+
+ + + +
+
+
+
+
+ + + + + + + + + + + + + +
+ +
+
+
+
+
+ + + + + +
+ + + +`; + +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]} + > + +
+
+ +
+ + + + + + + + +
+
+
+ + + + + +
+
+ +
+ +
+
+ +
+ +

+ + 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]} + > + +
+
+ +
+ + + + + + + + +
+
+
+ + + + + +
+
+ +
+ +
+
+ +
+ +

+ + 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. + +

+
+ + + + +
+
+ +
+
+ + + +
+
+ Option #1 +
+
+
+
+
+ +
+
+
+ } + 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], + } + } + > + +
+
+ + + +
+
+ Option #1 +
+
+
+
+
+ +
+
+
+ } + 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], + } + } + > + +
+
+ + + +
+
+ Option #1 +
+
+
+
+
+ +
+
+
+ } + onActivation={[Function]} + onDeactivation={[Function]} + persistentFocus={false} + returnFocus={[Function]} + shards={Array []} + > + +
+
+ + + +
+
+ Option #1 +
+
+
+
+
+ +
+
+
+ } + onActivation={[Function]} + onDeactivation={[Function]} + persistentFocus={false} + returnFocus={[Function]} + shards={Array []} + /> + + + + +
+
+
+ + + + + +
+ +
+ Option #1 +
+
+
+
+
+
+
+
+ + + +
+
+
+
+
+ + + + + + + + + + + + + +
+ +
+
+
+
+
+ + + + + +
+ + + +`; + +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]} + > + +
+
+ +
+ + + + + + + + +
+
+
+ + + + + +
+
+ +
+ +
+
+ +
+ +

+ + 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. + +

+
+ + + + +
+
+ +
+
+ + + + +
+
+ Option #1 +
+
+
+
+
+ +
+
+
+ } + 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], + } + } + > + +
+
+ + + + +
+
+ Option #1 +
+
+
+
+
+ +
+
+
+ } + 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], + } + } + > + +
+
+ + + + +
+
+ Option #1 +
+
+
+
+
+ +
+
+
+ } + onActivation={[Function]} + onDeactivation={[Function]} + persistentFocus={false} + returnFocus={[Function]} + shards={Array []} + > + +
+
+ + + + +
+
+ Option #1 +
+
+
+
+
+ +
+
+
+ } + onActivation={[Function]} + onDeactivation={[Function]} + persistentFocus={false} + returnFocus={[Function]} + shards={Array []} + /> + + + + +
+
+
+ + + + + + + + +
+ +
+ Option #1 +
+
+
+
+
+
+
+
+ + + +
+
+
+
+
+ + + + + + + + + + + + + +
+ +
+
+
+
+
+ + + + + +
+ + + +`; + +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]} + > + +
+
+ +
+ + + + + + + + +
+
+
+ + + + + +
+
+ +
+ +
+
+ +
+ +

+ + 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. + +

+
+ + + + +
+
+ +
+
+ + + + +
+
+ Option #1 +
+
+
+
+
+ + +
+
+
+ } + 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], + } + } + > + +
+
+ + + + +
+
+ Option #1 +
+
+
+
+
+ + +
+
+
+ } + 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], + } + } + > + +
+
+ + + + +
+
+ Option #1 +
+
+
+
+
+ + +
+
+
+ } + onActivation={[Function]} + onDeactivation={[Function]} + persistentFocus={false} + returnFocus={[Function]} + shards={Array []} + > + +
+
+ + + + +
+
+ Option #1 +
+
+
+
+
+ + +
+
+
+ } + onActivation={[Function]} + onDeactivation={[Function]} + persistentFocus={false} + returnFocus={[Function]} + shards={Array []} + /> + + + + +
+
+
+ + + + + + + + +
+ +
+ Option #1 +
+
+
+
+
+
+
+
+ + + + + + +
+
+
+
+
+ + + + + + + + + + + + + +
+ +
+
+
+
+
+ + + + + +
+ + + +`; + +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 0000000000..46cd8059a8 --- /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`] = ` +
+ + +
+`; + +exports[`OuiSplitButtonControl props fullWidth is rendered 1`] = ` +
+ + +
+`; + +exports[`OuiSplitButtonControl props isLoading is rendered 1`] = ` +
+ + +
+`; diff --git a/src/components/split_button/_index.scss b/src/components/split_button/_index.scss new file mode 100644 index 0000000000..05c66a515a --- /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 0000000000..52ceed59f8 --- /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 0000000000..97f447a698 --- /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 0000000000..1458ddc0f5 --- /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 0000000000..0e30a381d9 --- /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 0000000000..860c52dc1b --- /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"> + +

+ +

+
+ +
+
+ {items} +
+
+
+
+ ); +}; 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 0000000000..411cad513d --- /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 0000000000..b59b627978 --- /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} + + +
+ ); +};