diff --git a/CODEOWNERS b/CODEOWNERS index 6e86be4c..0b8b3309 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1,4 +1,5 @@ * @amje @ValeraS @korvin89 +/src/components/DefinitionList @Raubzeug /src/components/FilePreview @KirillDyachkovskiy /src/components/FormRow @ogonkov /src/components/HelpPopover @Raubzeug diff --git a/src/components/DefinitionList/DefinitionList.scss b/src/components/DefinitionList/DefinitionList.scss new file mode 100644 index 00000000..a82f7b80 --- /dev/null +++ b/src/components/DefinitionList/DefinitionList.scss @@ -0,0 +1,106 @@ +@use '../variables'; +@use '@gravity-ui/uikit/styles/mixins'; + +$block: '.#{variables.$ns}definition-list'; + +#{$block} { + $class: &; + + margin: 0; + + &__item { + display: flex; + align-items: baseline; + gap: var(--g-spacing-1); + + & + & { + margin-block-start: var(--g-spacing-4); + } + } + + &__term-container { + flex: 0 0 300px; + display: flex; + align-items: baseline; + + overflow: hidden; + position: relative; + } + + &__term-wrapper { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + flex: 0 1 auto; + color: var(--g-color-text-secondary); + + position: relative; + } + + &__term-container_multiline &__term-wrapper { + white-space: unset; + } + + &__term-container_multiline &__item-note-tooltip { + position: absolute; + } + + &__dots { + box-sizing: border-box; + flex: 1 0 auto; + min-width: 40px; + margin: 0 2px; + border-block-end: 1px dotted var(--g-color-line-generic-active); + } + + &__dots_with-note { + margin-inline-start: 15px; + min-width: 25px; + } + + &__definition { + flex: 0 1 auto; + margin: 0; + } + + &_responsive { + #{$block}__term-container { + flex: 1 0 auto; + } + } + + &__copy-container { + position: relative; + display: inline-flex; + padding-inline-end: var(--g-spacing-7); + + margin-inline-end: calc(-1 * var(--g-spacing-7)); + + &:hover { + #{$block}__copy-button { + opacity: 1; + } + } + } + + &__copy-container_icon-inside { + padding-inline-end: unset; + margin-inline-end: unset; + + #{$block}__copy-button { + inset-block-start: 0; + } + } + + &__copy-button { + position: absolute; + display: inline-block; + inset-inline-end: 0; + margin-inline-start: 10px; + opacity: 0; + &:focus-visible { + opacity: 1; + } + } +} diff --git a/src/components/DefinitionList/DefinitionList.tsx b/src/components/DefinitionList/DefinitionList.tsx new file mode 100644 index 00000000..2555adbb --- /dev/null +++ b/src/components/DefinitionList/DefinitionList.tsx @@ -0,0 +1,156 @@ +import React from 'react'; + +import {ClipboardButton, QAProps} from '@gravity-ui/uikit'; + +import {HelpPopover} from '../HelpPopover'; +import type {HelpPopoverProps} from '../HelpPopover'; +import {block} from '../utils/cn'; + +import {isUnbreakableOver} from './utils'; + +import './DefinitionList.scss'; + +type DefinitionListItemNote = string | HelpPopoverProps; + +export interface DefinitionListItem { + name: React.ReactNode; + content?: React.ReactNode; + contentTitle?: string; + nameTitle?: string; + copyText?: string; + note?: DefinitionListItemNote; + multilineName?: boolean; +} + +export interface DefinitionListProps extends QAProps { + items: DefinitionListItem[]; + copyPosition?: 'inside' | 'outside'; + responsive?: boolean; + nameMaxWidth?: number; + contentMaxWidth?: number | 'auto'; + className?: string; + itemClassName?: string; +} + +export const b = block('definition-list'); + +function getTitle(title?: string, content?: React.ReactNode) { + if (title) { + return title; + } + + if (typeof content === 'string' || typeof content === 'number') { + return String(content); + } + + return undefined; +} + +function getNoteElement(note?: DefinitionListItemNote) { + let noteElement = null; + const popoverClassName = b('item-note-tooltip'); + if (note) { + if (typeof note === 'string') { + noteElement = ( + + ); + } + + if (typeof note === 'object') { + noteElement = ( + + ); + } + } + return noteElement; +} + +export function DefinitionList({ + items, + responsive, + nameMaxWidth, + contentMaxWidth = 'auto', + className, + itemClassName, + copyPosition = 'outside', + qa, +}: DefinitionListProps) { + const keyStyle = nameMaxWidth + ? { + flexBasis: nameMaxWidth, + } + : {}; + + const valueStyle = + typeof contentMaxWidth === 'number' + ? { + flexBasis: contentMaxWidth, + maxWidth: contentMaxWidth, + } + : {}; + const normalizedItems = React.useMemo(() => { + return items.map((value, index) => ({...value, key: index})); + }, [items]); + return ( +
+ {normalizedItems.map( + ({name, key, content, contentTitle, nameTitle, copyText, note, multilineName}) => { + const definitionContent = content ?? '—'; + const iconInside = copyPosition === 'inside'; + const definition = copyText ? ( +
+ {definitionContent} + +
+ ) : ( + definitionContent + ); + const noteElement = ( + +   + {getNoteElement(note)} + + ); + return ( +
+
+
+ {name} + {multilineName && noteElement} +
+ {!multilineName && noteElement} +
+
+
+ {definition} +
+
+ ); + }, + )} +
+ ); +} diff --git a/src/components/DefinitionList/README.md b/src/components/DefinitionList/README.md new file mode 100644 index 00000000..cd0bbf64 --- /dev/null +++ b/src/components/DefinitionList/README.md @@ -0,0 +1,44 @@ +## DefinitionList + +The component to display definition list with term and definition separated by dots. + +### PropTypes + +| Property | Type | Required | Default | Description | +| :-------------- | :---------------------- | :-------: | :------ | :----------------------------------------------------------- | +| [items](#items) | `DefinitionListItem[]` | yes | | Items of the list | +| responsive | `boolean` | | | If set to `true` list will take 100% width of its parent | +| nameMaxWidth | `number` | | | Maximum width of term | +| contentMaxWidth | `number \| 'auto'` | | 'auto' | Maximum width of definition | +| className | `string` | | | Class name for the list container | +| itemClassName | `string` | | | Class name for the list item | +| copyPosition | `'inside' \| 'outside'` | 'outside' | | If set to `inside`, copy icon will be placed over definition | + +#### Items + +Configuration for list items + +| Property | Type | Required | Default | Description | +| ------------- | ---------------------------- | -------- | ------- | -------------------------------------------------------------- | +| name | `ReactNode` | true | | Term | +| multilineName | `boolean` | | | If set, term will be multiline | +| content | `ReactNode` | | | Definition | +| contentTitle | `string` | | | Title for definition. If not set, `content` value will be used | +| nameTitle | `string` | | | Title for term. If not set, `name` value will be used | +| copyText | `string` | | | If set, it will be shown icon for copy this text | +| note | `string \| HelpPopoverProps` | | | If set, HelpPopover will be shown next to term | + +```jsx +value with copy, + copyText: 'value', + }, + {name: 'Empty value with copy', copyText: 'nothing to copy'}, + ]} + nameMaxWidth="100" + contentMaxWidth="100" +/> +``` diff --git a/src/components/DefinitionList/__stories__/DefinitionList.stories.tsx b/src/components/DefinitionList/__stories__/DefinitionList.stories.tsx new file mode 100644 index 00000000..53e4597c --- /dev/null +++ b/src/components/DefinitionList/__stories__/DefinitionList.stories.tsx @@ -0,0 +1,146 @@ +import React from 'react'; + +import {Label, Link, User} from '@gravity-ui/uikit'; +import {Meta, StoryFn} from '@storybook/react'; + +import {DefinitionList, DefinitionListItem, DefinitionListProps} from '../DefinitionList'; + +const items: DefinitionListItem[] = [ + {name: String value, content: 'value'}, + { + name: ( + + ), + content: 'value', + note: 'This is avatar', + }, + {name: 'Number value', content: 2}, + {name: 'Node value', content: value}, + {name: 'Empty value'}, + {name: 'String value with copy', content: 'value', copyText: 'value'}, + {name: 'Number value with copy', content: 2, copyText: 'two'}, + {name: 'Node value with copy', content: value, copyText: 'value'}, + {name: 'Empty value with copy', copyText: 'nothing to copy'}, + {name: 'String value with custom title', content: 'value', contentTitle: "value's title"}, + {name: 'Number value with custom title', content: 2, contentTitle: "value's title"}, + { + name: 'Node value with custom title', + content: value, + contentTitle: "value's title", + }, + {name: 'Empty value with custom title', contentTitle: "value's title"}, + { + name: 'String long value', + content: + 'The HTML
element represents a description list. The element encloses a list of groups of terms (specified using the
element) and descriptions (provided by
elements). Common uses for this element are to implement a glossary or to display metadata (a list of key-value pairs)', + }, + { + name: 'String long value with copy', + content: + 'The HTML
element represents a description list. The element encloses a list of groups of terms (specified using the
element) and descriptions (provided by
elements). Common uses for this element are to implement a glossary or to display metadata (a list of key-value pairs)', + copyText: + 'The HTML
element represents a description list. The element encloses a list of groups of terms (specified using the
element) and descriptions (provided by
elements). Common uses for this element are to implement a glossary or to display metadata (a list of key-value pairs)', + }, + { + name: 'Number long value', + // eslint-disable-next-line no-loss-of-precision + content: 12345678901234567890123456789012345678901234567890123456789012345678901234567890, + }, + { + name: 'Node long value', + content: ( + + The{' '} + + HTML <dl>{' '} + + element represents a description list. The element encloses a list of groups of + terms (specified using the{' '} + + <dt> + {' '} + element) and descriptions (provided by{' '} + + <dd> + {' '} + elements). Common uses for this element are to implement a glossary or to display + metadata (a list of key-value pairs). + + ), + }, + { + name: 'String long value without whitespace', + content: + 'https://example.com/long-long/like/beyond/the/farthest/lands/long/path/to/handle?and=some&list=of&query=parameters&that=is&overcomplicated=maybe&with=some&token=inside¬=really&readable=but&sometimes=useful', + }, + { + name: 'String long looooooooooooooong looooooooooooooong looooooooooooooong looooooooooooooong value without multiline and with copy icon', + multilineName: true, + note: 'This is multiline value', + content: + 'https://example.com/long-long/like/beyond/the/farthest/lands/long/path/to/handle?and=some&list=of&query=parameters&that=is&overcomplicated=maybe&with=some&token=inside¬=really&readable=but&sometimes=useful', + copyText: + 'https://example.com/long-long/like/beyond/the/farthest/lands/long/path/to/handle?and=some&list=of&query=parameters&that=is&overcomplicated=maybe&with=some&token=inside¬=really&readable=but&sometimes=useful', + }, + { + name: 'String value with tooltip', + content: 'value', + note: 'This is simple string value', + }, + { + name: 'String value with very very very looooooooooooooong key', + content: 'value', + }, + { + name: 'String value with very very very looooooooooooooong key and tooltip', + content: 'value', + note: 'This is simple string value', + }, + { + name: 'Avatar with tooltip', + content: ( + + ), + note: 'This is avatar', + }, + { + name: 'Label', + content: , + }, +]; + +export default { + title: 'Components/DefinitionList', + component: DefinitionList, + args: { + items, + responsive: false, + contentMaxWidth: 480, + }, +} as Meta; + +const DefaultTemplate: StoryFn = (args) => ; +export const Default = DefaultTemplate.bind({}); + +const TemplateWithIconInside: StoryFn = (args) => { + return ( + e.copyText)} copyPosition="inside" /> + ); +}; +export const ListWithIconInside = TemplateWithIconInside.bind({}); diff --git a/src/components/DefinitionList/__tests__/DefinitionList.test.tsx b/src/components/DefinitionList/__tests__/DefinitionList.test.tsx new file mode 100644 index 00000000..ea1d0f05 --- /dev/null +++ b/src/components/DefinitionList/__tests__/DefinitionList.test.tsx @@ -0,0 +1,72 @@ +import React from 'react'; + +import {render, screen} from '../../../../test-utils/utils'; +import {DefinitionList, b} from '../DefinitionList'; + +const qaAttribute = 'definition-list'; + +const getComponent = (props = {}) => + render( + node value}, + ]} + {...props} + />, + ).container; + +describe('components: DefinitionList', () => { + it('should render', () => { + getComponent(); + const component = screen.getByTestId(qaAttribute); + expect(component).toBeVisible(); + }); + it('should render passed className', () => { + getComponent({className: 'testClassName'}); + const component = screen.getByTestId(qaAttribute); + expect(component).toHaveClass('testClassName'); + }); + + it('should render passed content title', () => { + const items = [{name: 'test1', content: 'value1', contentTitle: 'contentTitle1'}]; + getComponent({items}); + const component = screen.getByText('value1'); + expect(component).toHaveAttribute('title', 'contentTitle1'); + }); + it('should render passed name title', () => { + const items = [{name: 'test1', nameTitle: 'nameTitle1'}]; + getComponent({items}); + const component = screen.getByText('test1'); + expect(component).toHaveAttribute('title', 'nameTitle1'); + }); + it('should not render clipboard button by default', () => { + getComponent(); + const copyButton = screen.queryByRole('button'); + expect(copyButton).toBeNull(); + }); + it('should render clipboard button', () => { + const items = [{name: 'test1', content: 'value1', copyText: 'value1'}]; + getComponent({items}); + + const copyButton = screen.getByRole('button'); + + expect(copyButton).toHaveClass(b('copy-button')); + }); + it('should render in responsive mode', () => { + const items = [{name: 'test1', content: 'value1', copyText: 'value1'}]; + getComponent({items, responsive: true}); + + const component = screen.getByTestId(qaAttribute); + expect(component).toHaveClass(b({responsive: true})); + }); + it('should render with multiline term', () => { + const items = [{name: 'test1', content: 'value1', copyText: 'value1', multilineName: true}]; + getComponent({items}); + + const component = screen.getByRole('term'); + expect(component).toHaveClass(b('term-container', {multiline: true})); + }); +}); diff --git a/src/components/DefinitionList/index.ts b/src/components/DefinitionList/index.ts new file mode 100644 index 00000000..881123f2 --- /dev/null +++ b/src/components/DefinitionList/index.ts @@ -0,0 +1,2 @@ +export {DefinitionList} from './DefinitionList'; +export type {DefinitionListProps, DefinitionListItem} from './DefinitionList'; diff --git a/src/components/DefinitionList/utils.ts b/src/components/DefinitionList/utils.ts new file mode 100644 index 00000000..b7d4b485 --- /dev/null +++ b/src/components/DefinitionList/utils.ts @@ -0,0 +1,7 @@ +export function isUnbreakableOver(limit: number) { + return function (value: string): boolean { + const posibleLines = value.split(/\s+/); + + return posibleLines.some((line) => line.length > limit); + }; +} diff --git a/src/components/index.ts b/src/components/index.ts index bedffe9c..f80f556c 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,6 +1,7 @@ export * from './AdaptiveTabs'; export * from './CookieConsent'; export * from './ChangelogDialog'; +export * from './DefinitionList'; export * from './DelayedTextInput'; export * from './FilePreview'; export * from './FormRow';