diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 2b603eb174801..04096841d133a 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -390,6 +390,7 @@ src/platform/packages/private/kbn-transpose-utils @elastic/kibana-visualizations src/platform/packages/private/kbn-ui-shared-deps-npm @elastic/kibana-operations src/platform/packages/private/kbn-ui-shared-deps-src @elastic/kibana-operations src/platform/packages/private/kbn-unsaved-changes-badge @elastic/kibana-data-discovery +src/platform/packages/private/kbn-split-button @elastic/kibana-data-discovery src/platform/packages/private/kbn-validate-oas @elastic/kibana-core src/platform/packages/private/opentelemetry/kbn-metrics @elastic/kibana-core @elastic/stack-monitoring src/platform/packages/private/opentelemetry/kbn-metrics-config @elastic/kibana-core diff --git a/package.json b/package.json index ca42ef40e40d4..0fd4d32f264b5 100644 --- a/package.json +++ b/package.json @@ -1002,6 +1002,7 @@ "@kbn/spaces-plugin": "link:x-pack/platform/plugins/shared/spaces", "@kbn/spaces-test-plugin": "link:x-pack/platform/test/spaces_api_integration/common/plugins/spaces_test_plugin", "@kbn/spaces-utils": "link:src/platform/packages/shared/kbn-spaces-utils", + "@kbn/split-button": "link:src/platform/packages/private/kbn-split-button", "@kbn/sse-example-plugin": "link:examples/sse_example", "@kbn/sse-utils": "link:src/platform/packages/shared/kbn-sse-utils", "@kbn/sse-utils-client": "link:src/platform/packages/shared/kbn-sse-utils-client", diff --git a/src/platform/packages/private/kbn-split-button/.storybook/main.js b/src/platform/packages/private/kbn-split-button/.storybook/main.js new file mode 100644 index 0000000000000..4c71be3362b05 --- /dev/null +++ b/src/platform/packages/private/kbn-split-button/.storybook/main.js @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +module.exports = require('@kbn/storybook').defaultConfig; diff --git a/src/platform/packages/private/kbn-split-button/README.md b/src/platform/packages/private/kbn-split-button/README.md new file mode 100644 index 0000000000000..3964f8487461b --- /dev/null +++ b/src/platform/packages/private/kbn-split-button/README.md @@ -0,0 +1,3 @@ +# @kbn/split-button + +Empty package generated by @kbn/generate diff --git a/src/platform/packages/private/kbn-split-button/index.ts b/src/platform/packages/private/kbn-split-button/index.ts new file mode 100644 index 0000000000000..5df182598de18 --- /dev/null +++ b/src/platform/packages/private/kbn-split-button/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export { SplitButton } from './src/split_button'; diff --git a/src/platform/packages/private/kbn-split-button/jest.config.js b/src/platform/packages/private/kbn-split-button/jest.config.js new file mode 100644 index 0000000000000..748b5f1d75ffb --- /dev/null +++ b/src/platform/packages/private/kbn-split-button/jest.config.js @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../../../..', + roots: ['/src/platform/packages/private/kbn-split-button'], +}; diff --git a/src/platform/packages/private/kbn-split-button/kibana.jsonc b/src/platform/packages/private/kbn-split-button/kibana.jsonc new file mode 100644 index 0000000000000..f092bbd837719 --- /dev/null +++ b/src/platform/packages/private/kbn-split-button/kibana.jsonc @@ -0,0 +1,7 @@ +{ + "type": "shared-common", + "id": "@kbn/split-button", + "owner": "@elastic/kibana-data-discovery", + "group": "platform", + "visibility": "private" +} diff --git a/src/platform/packages/private/kbn-split-button/package.json b/src/platform/packages/private/kbn-split-button/package.json new file mode 100644 index 0000000000000..c3d84a5ced7b3 --- /dev/null +++ b/src/platform/packages/private/kbn-split-button/package.json @@ -0,0 +1,6 @@ +{ + "name": "@kbn/split-button", + "private": true, + "version": "1.0.0", + "license": "Elastic License 2.0 OR AGPL-3.0-only OR SSPL-1.0" +} \ No newline at end of file diff --git a/src/platform/packages/private/kbn-split-button/src/split_button.stories.tsx b/src/platform/packages/private/kbn-split-button/src/split_button.stories.tsx new file mode 100644 index 0000000000000..dc92f30e7464b --- /dev/null +++ b/src/platform/packages/private/kbn-split-button/src/split_button.stories.tsx @@ -0,0 +1,187 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; +import { SplitButton } from './split_button'; + +const DEFAULT_SECONDARY_ICON = 'clock'; + +export default { + title: 'Split Button', +}; + +export const Default = { + name: 'Default', + args: { + secondaryButtonIcon: DEFAULT_SECONDARY_ICON, + }, + render: (args: { secondaryButtonIcon: string }) => Default, +}; + +export const BothIcons = { + name: 'Both icons', + args: { + secondaryButtonIcon: DEFAULT_SECONDARY_ICON, + iconType: 'search', + }, + render: (args: { secondaryButtonIcon: string; iconType: string }) => ( + Both icons + ), +}; + +export const TextColor = { + name: 'Text color', + args: { + color: 'text', + }, + render: (args: { color: React.ComponentProps['color'] }) => ( + + Default + + ), +}; + +export const AccentColor = { + name: 'Accent color', + args: { + color: 'accent', + }, + render: (args: { color: React.ComponentProps['color'] }) => ( + + Default + + ), +}; + +export const DangerColor = { + name: 'Danger color', + args: { + color: 'danger', + }, + render: (args: { color: React.ComponentProps['color'] }) => ( + + Default + + ), +}; + +export const SuccessColor = { + name: 'Success color', + args: { + color: 'success', + }, + render: (args: { color: React.ComponentProps['color'] }) => ( + + Default + + ), +}; + +export const WarningColor = { + name: 'Warning color', + args: { + color: 'warning', + }, + render: (args: { color: React.ComponentProps['color'] }) => ( + + Default + + ), +}; + +export const MediumSize = { + name: 'Medium size', + args: { + size: 'm', + }, + render: (args: { size: React.ComponentProps['size'] }) => ( + + Medium size + + ), +}; + +export const SmallSize = { + name: 'Small size', + args: { + size: 's', + }, + render: (args: { size: React.ComponentProps['size'] }) => ( + + Small size + + ), +}; + +export const Disabled = { + name: 'Disabled', + args: { + isDisabled: true, + disabled: true, + }, + render: (args: { isDisabled: boolean; disabled: boolean }) => ( + + Small size + + ), +}; + +export const AllLoading = { + name: 'All loading', + args: { + isLoading: true, + isMainButtonLoading: true, + isSecondaryButtonLoading: true, + }, + render: (args: { + isLoading: boolean; + isMainButtonLoading: boolean; + isSecondaryButtonLoading: boolean; + }) => ( + + Small size + + ), +}; + +export const MainButtonLoading = { + name: 'Main button loading', + args: { + isLoading: false, + isMainButtonLoading: true, + isSecondaryButtonLoading: false, + }, + render: (args: { + isLoading: boolean; + isMainButtonLoading: boolean; + isSecondaryButtonLoading: boolean; + }) => ( + + Small size + + ), +}; + +export const SecondaryButtonLoading = { + name: 'Secondary button loading', + args: { + isLoading: false, + isMainButtonLoading: false, + isSecondaryButtonLoading: true, + }, + render: (args: { + isLoading: boolean; + isMainButtonLoading: boolean; + isSecondaryButtonLoading: boolean; + }) => ( + + Small size + + ), +}; diff --git a/src/platform/packages/private/kbn-split-button/src/split_button.test.tsx b/src/platform/packages/private/kbn-split-button/src/split_button.test.tsx new file mode 100644 index 0000000000000..aee0ca1778ec5 --- /dev/null +++ b/src/platform/packages/private/kbn-split-button/src/split_button.test.tsx @@ -0,0 +1,98 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { render, screen } from '@testing-library/react'; +import { SplitButton } from './split_button'; +import React from 'react'; +import userEvent from '@testing-library/user-event'; + +const setup = (props: Partial> = {}) => { + const secondaryButtonIcon = 'clock'; + const onMainButtonClick = jest.fn(); + const onSecondaryButtonClick = jest.fn(); + + const user = userEvent.setup(); + + render( + + ); + + return { + secondaryButtonIcon, + onMainButtonClick, + onSecondaryButtonClick, + user, + }; +}; + +describe('', () => { + describe('given a primary icon', () => { + it('should render the primary button icon', () => { + // Given + const primaryButtonIcon = 'plus'; + setup({ iconType: primaryButtonIcon }); + + // When + const primaryButton = screen.getByTestId('split-button-primary-button'); + + // Then + expect(primaryButton).toBeVisible(); + expect(primaryButton).toHaveAttribute('data-icon', primaryButtonIcon); + }); + }); + + describe('given a secondary icon', () => { + it('should render the secondary button icon', () => { + // Given + const secondaryButtonIcon = 'clock'; + + // When + setup({ secondaryButtonIcon }); + + // Then + const secondaryButton = screen.getByTestId('split-button-secondary-button'); + expect(secondaryButton).toBeVisible(); + expect(secondaryButton).toHaveAttribute('data-icon', secondaryButtonIcon); + }); + }); + + describe('when the primary button is clicked', () => { + it('should call the onClick handler', async () => { + // Given + const { user, onMainButtonClick } = setup(); + + // When + const primaryButton = screen.getByTestId('split-button-primary-button'); + await user.click(primaryButton); + + // Then + expect(onMainButtonClick).toHaveBeenCalled(); + }); + }); + + describe('when the secondary button is clicked', () => { + it('should call the onSecondaryButtonClick handler', async () => { + // Given + const { user, onSecondaryButtonClick } = setup(); + + // When + const secondaryButton = screen.getByTestId('split-button-secondary-button'); + await user.click(secondaryButton); + + // Then + expect(onSecondaryButtonClick).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/platform/packages/private/kbn-split-button/src/split_button.tsx b/src/platform/packages/private/kbn-split-button/src/split_button.tsx new file mode 100644 index 0000000000000..95800f502bee3 --- /dev/null +++ b/src/platform/packages/private/kbn-split-button/src/split_button.tsx @@ -0,0 +1,113 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { EuiButton, EuiButtonIcon, useEuiTheme } from '@elastic/eui'; +import type { UseEuiTheme } from '@elastic/eui'; +import { useMemoCss } from '@kbn/css-utils/public/use_memo_css'; +import React from 'react'; + +type SplitButtonProps = React.ComponentProps & { + isMainButtonLoading?: boolean; + iconOnly?: boolean; + + isSecondaryButtonLoading?: boolean; + secondaryButtonIcon: string; + secondaryButtonAriaLabel?: string; + onSecondaryButtonClick?: React.MouseEventHandler; +}; + +export const SplitButton = ({ + // Common props + isDisabled = false, + disabled = false, + isLoading = false, + color = 'primary', + size = 'm', + + // Secondary button props + isSecondaryButtonLoading = false, + secondaryButtonIcon, + secondaryButtonAriaLabel, + onSecondaryButtonClick, + + // Primary button props + isMainButtonLoading = false, + iconOnly = false, + iconType, + ...mainButtonProps +}: SplitButtonProps) => { + const styles = useMemoCss(componentStyles); + const { euiTheme } = useEuiTheme(); + + const hasTransparentBorder = color !== 'text'; + const borderColor = hasTransparentBorder ? 'transparent' : euiTheme.colors.borderBasePlain; + + const areButtonsDisabled = disabled || isDisabled; + + const commonMainButtonProps = { + css: styles.mainButton, + style: { + borderRightColor: borderColor, + }, + color, + size, + isDisabled: areButtonsDisabled, + isLoading: isLoading || isMainButtonLoading, + 'data-icon': iconType, + ...mainButtonProps, + 'data-test-subj': mainButtonProps['data-test-subj'] + '-primary-button', + }; + + return ( +
+ {iconOnly && iconType ? ( + + ) : ( + + )} + +
+ ); +}; + +const componentStyles = { + container: { + display: 'flex', + }, + containerWithGap: { + gap: '1px', + }, + mainButton: ({ euiTheme }: UseEuiTheme) => { + return { + borderTopRightRadius: 0, + borderBottomRightRadius: 0, + borderRight: `${euiTheme.border.thin} solid`, + }; + }, + secondaryButton: { + borderTopLeftRadius: 0, + borderBottomLeftRadius: 0, + borderLeft: 'none', + }, +}; diff --git a/src/platform/packages/private/kbn-split-button/tsconfig.json b/src/platform/packages/private/kbn-split-button/tsconfig.json new file mode 100644 index 0000000000000..586a09b930b68 --- /dev/null +++ b/src/platform/packages/private/kbn-split-button/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types", + "types": [ + "jest", + "node", + "react", + "@emotion/react/types/css-prop", + "@testing-library/jest-dom", + "@testing-library/react" + ] + }, + "include": ["**/*.ts", "**/*.tsx"], + "exclude": ["target/**/*"], + "kbn_references": ["@kbn/css-utils"] +} diff --git a/tsconfig.base.json b/tsconfig.base.json index 06162f56a4734..aba273d9a69ba 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -2050,6 +2050,8 @@ "@kbn/spaces-test-plugin/*": ["x-pack/platform/test/spaces_api_integration/common/plugins/spaces_test_plugin/*"], "@kbn/spaces-utils": ["src/platform/packages/shared/kbn-spaces-utils"], "@kbn/spaces-utils/*": ["src/platform/packages/shared/kbn-spaces-utils/*"], + "@kbn/split-button": ["src/platform/packages/private/kbn-split-button"], + "@kbn/split-button/*": ["src/platform/packages/private/kbn-split-button/*"], "@kbn/sse-example-plugin": ["examples/sse_example"], "@kbn/sse-example-plugin/*": ["examples/sse_example/*"], "@kbn/sse-utils": ["src/platform/packages/shared/kbn-sse-utils"], diff --git a/yarn.lock b/yarn.lock index aee55a3b9ef4a..1985b52961aa8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8068,6 +8068,10 @@ version "0.0.0" uid "" +"@kbn/split-button@link:src/platform/packages/private/kbn-split-button": + version "0.0.0" + uid "" + "@kbn/sse-example-plugin@link:examples/sse_example": version "0.0.0" uid ""