diff --git a/package.json b/package.json index 95bba04..7aeae72 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,8 @@ } ], "peerDependencies": { - "@consta/icons": "^1.1.1", - "@consta/uikit": "^5.26.0", + "@consta/icons": "^1.6.0", + "@consta/uikit": "^5.30.0", "@reatom/core": "3.10.1", "@reatom/npm-react": "3.10.6" }, diff --git a/src/components/Collapse/Collapse.css b/src/components/Collapse/Collapse.css index 875adb2..9ba78f1 100644 --- a/src/components/Collapse/Collapse.css +++ b/src/components/Collapse/Collapse.css @@ -1,22 +1,25 @@ .ct--Collapse { &-Content { display: grid; - grid-template-rows: 0fr; + grid-template-rows: 0; grid-template-columns: 100%; - transition: grid-template-rows 0.2s ease-in-out; + &_expanded { grid-template-rows: 1fr; } } + &-ChildrenWrapper { overflow: hidden; } + &-Title { display: flex; align-items: center; height: var(--control-height-s); } + &-Toolbar.ct--Toolbar { background: var(--color-bg-secondary); } diff --git a/src/components/Collapse/CollapseFullscreen/CollapseFullscreen.css b/src/components/Collapse/CollapseFullscreen/CollapseFullscreen.css index b87d737..805e26e 100644 --- a/src/components/Collapse/CollapseFullscreen/CollapseFullscreen.css +++ b/src/components/Collapse/CollapseFullscreen/CollapseFullscreen.css @@ -1,8 +1,8 @@ .ct--CollapseFullscreen { position: fixed; top: 0; - left: 0; right: 0; + left: 0; bottom: 0; background-color: var(--color-bg-default); @@ -13,9 +13,11 @@ &-ToolbarWrapper { height: var(--collapse-toolbar-height); } + &-ChildrenWrapper { height: calc(100% - var(--collapse-toolbar-height)); } + &-Toolbar.ct--Toolbar { background: var(--color-bg-secondary); } diff --git a/src/components/Collapse/__stand__/examples/CollapseExampleFullscreenContainer/CollapseExampleFullscreenContainer.css b/src/components/Collapse/__stand__/examples/CollapseExampleFullscreenContainer/CollapseExampleFullscreenContainer.css index 3c131d4..47d945c 100644 --- a/src/components/Collapse/__stand__/examples/CollapseExampleFullscreenContainer/CollapseExampleFullscreenContainer.css +++ b/src/components/Collapse/__stand__/examples/CollapseExampleFullscreenContainer/CollapseExampleFullscreenContainer.css @@ -6,14 +6,16 @@ &-Item { height: 360px; + .ct--Collapse-Content { - height: calc( - 100% - + height: + calc( + 100% - calc( var(--control-height-s) + var(--control-border-width) + - var(--space-xs) * 2 + var(--space-xs) * 2 ) - ); + ); } } diff --git a/src/components/DataCell/DataCell.css b/src/components/DataCell/DataCell.css index 770a960..0970ff6 100644 --- a/src/components/DataCell/DataCell.css +++ b/src/components/DataCell/DataCell.css @@ -1,12 +1,15 @@ /* --table-data-cell-level - задается в компоненте */ .ct--DataCell { - padding-right: var(--space-s); - padding-left: calc( + --table-data-cell-padding-right: var(--space-s); + --table-data-cell-padding-left: calc( var(--space-s) + (var(--table-data-cell-slot-width) + var(--space-2xs)) * var(--table-data-cell-level, 0) + var(--table-data-cell-additional-space, calc(0 * 1px)) ); + padding-right: var(--table-data-cell-padding-right); + padding-left: var(--table-data-cell-padding-left); + &_alignmentIndent { --table-data-cell-additional-space: calc( var(--table-data-cell-gap) - var(--space-2xs) @@ -55,10 +58,24 @@ &-ContentSlot { min-height: var(--table-data-cell-slot-height); + &_truncate { overflow: hidden; } } + + &-Text { + white-space: pre-wrap; + word-break: break-word; + + &_lineClamp { + display: -webkit-box; + overflow: hidden; + text-overflow: ellipsis; + -webkit-line-clamp: var(--table-data-cell-line-clamp); + -webkit-box-orient: vertical; + } + } } .MixFlex.ct--DataCell { diff --git a/src/components/DataCell/DataCell.tsx b/src/components/DataCell/DataCell.tsx index a9f9da4..eb1676e 100644 --- a/src/components/DataCell/DataCell.tsx +++ b/src/components/DataCell/DataCell.tsx @@ -21,6 +21,7 @@ export type DataCellProps = { size?: 'm' | 's'; indicator?: 'alert' | 'warning'; truncate?: boolean; + lineClamp?: number; } & JSX.IntrinsicElements['div']; const renderContentSlot = ( @@ -47,16 +48,20 @@ const renderChildren = ( view: DataCellProps['view'], size: 'm' | 's', truncate: boolean, + lineClamp?: number, ) => { if (isString(children) || isNumber(children)) { return renderContentSlot( {children} , @@ -79,6 +84,7 @@ export const DataCell = forwardRef( indicator, style, truncate = false, + lineClamp, ...otherProps } = props; const level = levelProp < 0 ? 0 : levelProp; @@ -98,8 +104,10 @@ export const DataCell = forwardRef( const childrenSlots = children ? [ ...(Array.isArray(children) - ? children.map((item) => renderChildren(item, view, size, truncate)) - : [renderChildren(children, view, size, truncate)]), + ? children.map((item) => + renderChildren(item, view, size, truncate, lineClamp), + ) + : [renderChildren(children, view, size, truncate, lineClamp)]), ] : []; @@ -122,6 +130,7 @@ export const DataCell = forwardRef( ['--table-data-cell-indicator-color' as string]: indicator ? `var(--color-bg-${indicator})` : undefined, + ['--table-data-cell-line-clamp' as string]: lineClamp || undefined, }} ref={ref} > diff --git a/src/components/DataCell/__stand__/DataCell.dev.stand.mdx b/src/components/DataCell/__stand__/DataCell.dev.stand.mdx index 6d69045..65575fe 100644 --- a/src/components/DataCell/__stand__/DataCell.dev.stand.mdx +++ b/src/components/DataCell/__stand__/DataCell.dev.stand.mdx @@ -37,6 +37,7 @@ import { DataCell } from '@consta/table/DataCell'; | [`view?`](#вид) | `'primary'` | `'alert'` | `'success'` | `'warning'` | - | Слот для контролов | | [`level?`](#уровень-вложенности) | `number` | `0` | Уровень вложенности | | `truncate?` | `boolean` | - | Заменяет переполненный текст на многоточие | +| `lineClamp?` | `number` | - | Обрезка текста по строкам | | `className?` | `string` | - | Дополнительный CSS-класс | | `ref?` | `React.Ref` | - | Ссылка на корневой DOM-элемент | diff --git a/src/components/DataCell/__stand__/DataCell.variants.tsx b/src/components/DataCell/__stand__/DataCell.variants.tsx index 8dfd4fd..32c9a3b 100644 --- a/src/components/DataCell/__stand__/DataCell.variants.tsx +++ b/src/components/DataCell/__stand__/DataCell.variants.tsx @@ -23,6 +23,7 @@ const Variants = () => { const text = useText('text', 'Значение ячейки'); const size = useSelect('size', ['m', 's'], 'm'); + const lineClamp = useNumber('lineClamp', 0); const view = useSelect( 'view', ['primary', 'alert', 'success', 'warning'], @@ -82,6 +83,7 @@ const Variants = () => { icon={widthIcon ? IconPhone : undefined} control={controlMap[control || 'without control']} indicator={indicator} + lineClamp={lineClamp} > {children} diff --git a/src/components/Table/TableCell/TableCell.css b/src/components/Table/TableCell/TableCell.css index c0fe6e3..d9ba2aa 100644 --- a/src/components/Table/TableCell/TableCell.css +++ b/src/components/Table/TableCell/TableCell.css @@ -1,4 +1,5 @@ .ct--TableCell { + --table-cell-edit-mode-border-color: var(--color-control-bg-border-focus); --table-cell-border-top: 0; --table-cell-border-bottom: 0; --table-cell-border-left: 0; @@ -48,4 +49,17 @@ &_up { z-index: 1; } + + &:has([data-cell-edit-mode='true']) { + box-shadow: 1px 1px 0 0 var(--table-cell-edit-mode-border-color) inset, + -1px -1px 0 0 var(--table-cell-edit-mode-border-color) inset; + } + + &:has([data-cell-status='alert']) { + --table-cell-edit-mode-border-color: var(--color-bg-alert); + } + + &:has([data-cell-status='warning']) { + --table-cell-edit-mode-border-color: var(--color-bg-warning); + } } diff --git a/src/components/Table/TableData/TableData.css b/src/components/Table/TableData/TableData.css index a480eb3..9b89389 100644 --- a/src/components/Table/TableData/TableData.css +++ b/src/components/Table/TableData/TableData.css @@ -1,6 +1,6 @@ .ct--TableData { - display: contents; --table-data-cell-bg: var(--color-bg-default); + display: contents; &-Cell { grid-column: span var(--table-cell-col-span, 1); @@ -16,41 +16,45 @@ display: contents; &_zebraStriped { - --table-data-cell-bg: color-mix( - in srgb, - rgb(from var(--color-bg-stripe) r g b / 1) 5%, - var(--color-bg-default) - ); + --table-data-cell-bg: + color-mix( + in srgb, + rgb(from var(--color-bg-stripe) r g b / 1) 5%, + var(--color-bg-default) + ); } &:has(> * > [data-row-active='true']) { - --table-data-cell-bg: color-mix( - in srgb, - rgb(from var(--color-control-bg-primary) r g b / 1) 15%, - var(--color-bg-default) - ); + --table-data-cell-bg: + color-mix( + in srgb, + rgb(from var(--color-control-bg-primary) r g b / 1) 15%, + var(--color-bg-default) + ); } &:has(> * > [data-row-hover='true']), .ct--TableData_rowHoverEffect &:hover { - --table-data-cell-bg: color-mix( - in srgb, - rgb(from var(--color-control-typo-ghost-hover) r g b / 1) 9%, - var(--color-bg-default) - ); + --table-data-cell-bg: + color-mix( + in srgb, + rgb(from var(--color-control-typo-ghost-hover) r g b / 1) 9%, + var(--color-bg-default) + ); } &:has(> * > [data-row-hover='true'][data-row-active='true']), .ct--TableData_rowHoverEffect &:hover:has(> * > [data-row-active='true']) { - --table-data-cell-bg: color-mix( - in srgb, - rgb(from var(--color-control-typo-ghost-hover) r g b / 1) 3%, + --table-data-cell-bg: color-mix( in srgb, - rgb(from var(--color-control-bg-primary) r g b / 1) 15%, - var(--color-bg-default) - ) - ); + rgb(from var(--color-control-typo-ghost-hover) r g b / 1) 3%, + color-mix( + in srgb, + rgb(from var(--color-control-bg-primary) r g b / 1) 15%, + var(--color-bg-default) + ) + ); } } } diff --git a/src/components/Table/TableResizers/TableResizers.css b/src/components/Table/TableResizers/TableResizers.css index c76b313..9704cc8 100644 --- a/src/components/Table/TableResizers/TableResizers.css +++ b/src/components/Table/TableResizers/TableResizers.css @@ -11,17 +11,19 @@ --inner-line-width: 1px; --fast-transition: 0.15s ease-out; --resizer-width: 4px; - --resizer-top: calc( - (var(--table-header-height) - var(--table-resizer-top-offset)) * -1 - ); + --resizer-top: + calc( + (var(--table-header-height) - var(--table-resizer-top-offset)) * -1 + ); position: absolute; top: var(--resizer-top); right: 0; width: var(--resizer-width); - height: calc( - var(--table-body-height) - var(--table-resizer-top-offset) - + height: + calc( + var(--table-body-height) - var(--table-resizer-top-offset) - var(--table-body-horizontal-scroll-height) - 2px - ); + ); background-color: var(--color-bg-ghost); opacity: 0; cursor: col-resize; @@ -45,10 +47,11 @@ opacity: 1; } } + &-VirtualScrollHelper { position: absolute; top: calc(var(--table-header-height) * -1); - pointer-events: none; width: 100%; + pointer-events: none; } } diff --git a/src/components/Table/TableRow/TableRow.css b/src/components/Table/TableRow/TableRow.css index 60c349a..080861c 100644 --- a/src/components/Table/TableRow/TableRow.css +++ b/src/components/Table/TableRow/TableRow.css @@ -6,6 +6,7 @@ &_left { grid-column: span var(--table-row-offset, 1); } + &_right { grid-column: span var(--table-row-offset, 1); } @@ -13,41 +14,45 @@ } &_zebraStriped { - --table-data-cell-bg: color-mix( - in srgb, - rgb(from var(--color-bg-stripe) r g b / 1) 5%, - var(--color-bg-default) - ); + --table-data-cell-bg: + color-mix( + in srgb, + rgb(from var(--color-bg-stripe) r g b / 1) 5%, + var(--color-bg-default) + ); } &:has(> * > [data-row-active='true']) { - --table-data-cell-bg: color-mix( - in srgb, - rgb(from var(--color-control-bg-primary) r g b / 1) 15%, - var(--color-bg-default) - ); + --table-data-cell-bg: + color-mix( + in srgb, + rgb(from var(--color-control-bg-primary) r g b / 1) 15%, + var(--color-bg-default) + ); } &:has(> * > [data-row-hover='true']), .ct--TableData_rowHoverEffect &:hover { - --table-data-cell-bg: color-mix( - in srgb, - rgb(from var(--color-control-typo-ghost-hover) r g b / 1) 9%, - var(--color-bg-default) - ); + --table-data-cell-bg: + color-mix( + in srgb, + rgb(from var(--color-control-typo-ghost-hover) r g b / 1) 9%, + var(--color-bg-default) + ); } &:has(> * > [data-row-hover='true'][data-row-active='true']), .ct--TableData_rowHoverEffect &:hover:has(> * > [data-row-active='true']) { - --table-data-cell-bg: color-mix( - in srgb, - rgb(from var(--color-control-typo-ghost-hover) r g b / 1) 3%, + --table-data-cell-bg: color-mix( in srgb, - rgb(from var(--color-control-bg-primary) r g b / 1) 15%, - var(--color-bg-default) - ) - ); + rgb(from var(--color-control-typo-ghost-hover) r g b / 1) 3%, + color-mix( + in srgb, + rgb(from var(--color-control-bg-primary) r g b / 1) 15%, + var(--color-bg-default) + ) + ); } &-Cell { diff --git a/src/components/Table/TableRowCell/TableRowCell.tsx b/src/components/Table/TableRowCell/TableRowCell.tsx index 4ce2423..629fcf7 100644 --- a/src/components/Table/TableRowCell/TableRowCell.tsx +++ b/src/components/Table/TableRowCell/TableRowCell.tsx @@ -102,6 +102,8 @@ const TableRowCellRender = ( ? `var(--table-column-sticky-right-offset-${index})` : undefined, gridColumn: `${index + 1} / span ${miss > 0 ? miss + 1 : 1}`, + ['--table-cell-grid-column-index' as string]: index, + ['--table-cell-grid-row-index' as string]: rowIndex, }} > {isNotNil(RenderCell) ? ( diff --git a/src/components/Table/__stand__/examples/TableExampleSimple/TableExampleSimple.tsx b/src/components/Table/__stand__/examples/TableExampleSimple/TableExampleSimple.tsx index b91ea8f..4db2856 100644 --- a/src/components/Table/__stand__/examples/TableExampleSimple/TableExampleSimple.tsx +++ b/src/components/Table/__stand__/examples/TableExampleSimple/TableExampleSimple.tsx @@ -16,6 +16,16 @@ const rows: Row[] = [ profession: 'Отвечает на вопросы, хотя его не спросили', status: 'на связи', }, + { + name: 'Василий', + profession: 'Отвечает на вопросы, хотя его не спросили', + status: 'на связи', + }, + { + name: 'Василий', + profession: 'Отвечает на вопросы, хотя его не спросили', + status: 'на связи', + }, ]; const columns: TableColumn[] = [ diff --git a/src/components/TextFieldCell/TextFieldCell.css b/src/components/TextFieldCell/TextFieldCell.css new file mode 100644 index 0000000..775f04b --- /dev/null +++ b/src/components/TextFieldCell/TextFieldCell.css @@ -0,0 +1,72 @@ +.ct--TextFieldCell { + width: 100%; + height: 100%; + min-height: var(--text-filed-cell-min-height); + padding-left: var( + --text-filed-cell-padding-left, + var(--table-data-cell-padding-left) + ); + padding-right: var( + --text-filed-cell-padding-right, + var(--table-data-cell-padding-right) + ); + + &_size { + &_s { + --text-filed-cell-min-height: var(--space-3xl); + } + + &_m { + --text-filed-cell-min-height: var(--space-4xl); + } + } + + &[data-cell-edit-mode='true'] { + &.ct--DataCell { + --text-filed-cell-padding-left: calc( + var(--table-data-cell-padding-right) - var(--space-s) + ); + --text-filed-cell-padding-right: calc( + var(--table-data-cell-padding-right) - var(--space-s) + ); + --field-control-layout-additional-padding-left: var(--space-s); + --field-control-layout-additional-padding-right: var(--space-s); + } + + & .ct--DataCell-Slots, + & .ct--DataCell-ContentSlot { + overflow: hidden; + flex: 1; + } + } + + &-Field { + flex: 1; + align-items: stretch; + width: 100%; + margin-top: 0; + margin-bottom: 0; + } + + & .TextFieldTypeTextArea-TextArea { + line-height: var(--line-height-text-m); + } + + & .FieldControlLayout-Children { + width: var(--field-control-layout-children-width); + } + + & .ct--DataCell-Slots:has(.TextFieldTypeTextArea_resize), + & .ct--DataCell-ContentSlot:has(.TextFieldTypeTextArea_resize), + & .TextFieldTypeTextArea_resize, + & .TextFieldTypeTextArea_resize .FieldControlLayout-Container, + & .TextFieldTypeTextArea_resize .FieldControlLayout-Children, + & .TextFieldTypeTextArea_resize .TextFieldTypeTextArea-TextArea { + min-height: 100%; + height: 100%; + } + + & .TextAreaAutoSize { + display: initial; + } +} diff --git a/src/components/TextFieldCell/TextFieldCell.tsx b/src/components/TextFieldCell/TextFieldCell.tsx new file mode 100644 index 0000000..a4c5913 --- /dev/null +++ b/src/components/TextFieldCell/TextFieldCell.tsx @@ -0,0 +1,211 @@ +import './TextFieldCell.css'; + +import { useClickOutside } from '@consta/uikit/__internal__/src/hooks/useClickOutside'; +import { + TextField, + TextFieldProps, + TextFieldPropValue, +} from '@consta/uikit/TextFieldCanary'; +import { useFlag } from '@consta/uikit/useFlag'; +import { useForkRef } from '@consta/uikit/useForkRef'; +import { useKeysRef } from '@consta/uikit/useKeysRef'; +import React, { forwardRef, useEffect, useRef } from 'react'; + +import { DataCell } from '##/components/DataCell'; +import { cn } from '##/utils/bem'; + +const cnTextFieldCell = cn('TextFieldCell'); + +export type TextFieldCellProps = Omit< + TextFieldProps, + 'view' | 'size' | 'form' | 'status' +> & { + size?: 's' | 'm'; + lineClamp?: number; + readModeRender?: (value?: TextFieldPropValue) => React.ReactNode; + level?: number; + truncate?: boolean; + status?: 'alert' | 'warning'; + indicator?: 'alert' | 'warning'; + readonly?: boolean; +}; + +type SplitProps = TextFieldCellProps<'textarea'> & + TextFieldCellProps<'text'> & + TextFieldCellProps<'textarray'> & + TextFieldCellProps<'password'> & + TextFieldCellProps<'number'>; + +const readModeRenderDefault = ( + value?: TextFieldPropValue, +) => value; + +export type TextFieldCellComponent = ( + props: TextFieldCellProps, +) => React.ReactNode | null; + +const TextFieldCellRender = ( + props: SplitProps, + ref: React.Ref, +) => { + const { + className, + size = 'm', + lineClamp, + type, + value, + readModeRender = readModeRenderDefault, + level, + truncate, + defaultValue, + onChange, + id, + name, + disabled, + maxLength, + minLength, + onFocus, + onBlur, + placeholder, + leftSide, + rightSide, + clearButton, + iconClear, + autoComplete, + readOnly, + tabIndex, + ariaLabel, + iconSize, + onClear, + inputRef: inputRefProp, + onKeyUp, + onKeyUpCapture, + onKeyDown, + onKeyDownCapture, + onCopy, + onCopyCapture, + onCut, + onCutCapture, + onPaste, + onPasteCapture, + onWheel, + max, + min, + step, + incrementButtons, + iconShowPassword, + iconHidePassword, + resize, + minRows, + maxRows, + rows, + renderValueItem, + inputValue, + onInputChange, + status, + indicator, + readonly, + ...restProps + } = props; + const refRoot = useRef(null); + const inputRef = useRef(null); + const inputRefForked = useForkRef([inputRef, inputRefProp]); + + const [editMode, setEditMode] = useFlag(); + + useClickOutside({ + isActive: editMode, + ignoreClicksInsideRefs: [refRoot], + handler: setEditMode.off, + }); + + useKeysRef({ + isActive: editMode, + ref: refRoot, + keys: { + Escape: setEditMode.off, + }, + }); + + useEffect(() => { + if (editMode) { + inputRef.current?.focus(); + } + }, [editMode]); + + const textFiledProps = { + className: cnTextFieldCell('Field'), + view: 'clear', + onDoubleClick: setEditMode.on, + value, + type, + inputRef: inputRefForked, + defaultValue, + onChange, + id, + name, + disabled, + maxLength, + minLength, + onFocus, + onBlur, + placeholder, + leftSide, + rightSide, + clearButton, + iconClear, + autoComplete, + readOnly, + tabIndex, + ariaLabel, + iconSize, + onClear, + onKeyUp, + onKeyUpCapture, + onKeyDown, + onKeyDownCapture, + max, + min, + step, + incrementButtons, + iconShowPassword, + iconHidePassword, + resize, + minRows, + maxRows, + rows, + renderValueItem, + inputValue, + onInputChange, + onCopy, + onCopyCapture, + onCut, + onCutCapture, + onPaste, + onPasteCapture, + onWheel, + size, + } as const; + + return ( + + {editMode ? : readModeRender(value)} + + ); +}; + +export const TextFieldCell = forwardRef( + TextFieldCellRender, +) as TextFieldCellComponent; diff --git a/src/components/TextFieldCell/__stand__/TextFieldCell.dev.stand.mdx b/src/components/TextFieldCell/__stand__/TextFieldCell.dev.stand.mdx new file mode 100644 index 0000000..0effdf7 --- /dev/null +++ b/src/components/TextFieldCell/__stand__/TextFieldCell.dev.stand.mdx @@ -0,0 +1,367 @@ +import { TextFieldCellExampleTypes } from './examples/TextFieldCellExampleTypes/TextFieldCellExampleTypes'; +import { TextFieldCellExampleAlertMessage } from './examples/TextFieldCellExampleAlertMessage/TextFieldCellExampleAlertMessage'; +import { MdxTabs } from '@consta/stand'; + +# Обзор + +`TextFieldCell` позволяет вводить текст, числа или массивы строк. Компонент базируется на [`TextField`](##LIBS.LIB.STAND/lib:uikit/stand:components-textfield-canary) и подготовлен для использования в таблицах. Основные свойства компонента идентичны [`TextField`](##LIBS.LIB.STAND/lib:uikit/stand:components-textfield-canary). + +# Тип + +Доступные типы компонента: + +- `text` - текст +- `textarea` - многострочный текст +- `number` - число +- `textarray` - массив строк + + + + + +```tsx +import React, { useCallback, useState } from 'react'; + +import { Table, TableColumn, TableRenderCell } from '@consta/table/Table'; +import { TextFieldCell } from '@consta/table/TextFieldCell'; + +type Row = { + text: string; + textarea: string; + textareaAutosize: string; + number: string; + textArray: string[]; +}; + +const rows: Row[] = [ + { + text: 'value1', + textarea: 'value2', + textareaAutosize: 'value3', + number: 'value4', + textArray: ['value5', 'value6'], + }, +]; + +const CellTypeText: TableRenderCell = (row) => { + const [value, setValue] = useState(row.row.text); + + return ( + + ); +}; + +const CellTypeTextArea: TableRenderCell = (row) => { + const [value, setValue] = useState(row.row.textarea); + + return ( + + ); +}; + +const CellTypeTextAreaAutosize: TableRenderCell = (row) => { + const [value, setValue] = useState(row.row.textareaAutosize); + + return ( + + ); +}; + +const CellTypeNumber: TableRenderCell = (row) => { + const [value, setValue] = useState(row.row.number); + + return ( + + ); +}; + +const CellTypeTextArray: TableRenderCell = (row) => { + const [value, setValue] = useState(row.row.textArray); + + const [inputValue, setInputValue] = useState(null); + const onChangeValueArray = useCallback((value: string[] | null) => { + setValue(value); + setInputValue(null); + }, []); + + return ( + value?.join(', ')} + size="m" + /> + ); +}; + +const columns: TableColumn[] = [ + { + title: 'TextArray', + accessor: 'textArray', + renderCell: CellTypeTextArray, + minWidth: 200, + }, + { + title: 'Text', + accessor: 'text', + renderCell: CellTypeText, + minWidth: 200, + }, + + { + title: 'Textarea', + accessor: 'textarea', + renderCell: CellTypeTextArea, + minWidth: 200, + }, + { + title: 'Textarea autosize', + accessor: 'textareaAutosize', + renderCell: CellTypeTextAreaAutosize, + minWidth: 200, + }, + + { + title: 'Number', + accessor: 'number', + renderCell: CellTypeNumber, + minWidth: 200, + }, +]; + +export const TextFieldCellExampleTypes = () => ( + +); +``` + + + +# Обработка ошибок + +В компоненте есть 2 свойства для отображения ошибок. + +`indicator` - отображает уголок в правом верхнем углу ячейки, в режиме чтения. +`status` - отображает обводку поля в режиме редактирования. + +Для вывода ошибки можете использовать [`Popover`](##LIBS.LIB.STAND/lib:uikit/stand:components-popover-stable) и [`Informer`](##LIBS.LIB.STAND/lib:uikit/stand:components-informer-stable) + +В примере снизу ошибка возникает если количество элементов в массиве больше 10. + + + + + +```tsx +import { Example } from '@consta/stand'; +import { Informer } from '@consta/uikit/Informer'; +import { + animateTimeout, + cnMixPopoverAnimate, +} from '@consta/uikit/MixPopoverAnimate'; +import { Popover } from '@consta/uikit/Popover'; +import { useClickOutside } from '@consta/uikit/useClickOutside'; +import { useFlag } from '@consta/uikit/useFlag'; +import { useHover } from '@consta/uikit/useHover'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { Transition } from 'react-transition-group'; + +import { Table, TableColumn, TableRenderCell } from '@consta/table/Table'; +import { TextFieldCell } from '@consta/table/TextFieldCell'; + +type Row = { + col: string; + col2: string; + textArray: string[]; +}; + +const rows: Row[] = [ + { + col: 'value1', + textArray: Array.from({ length: 11 }, (_, i) => `элемент-${i + 1}`), + col2: 'value2', + }, +]; + +const CellTypeTextArray: TableRenderCell = (row) => { + const popoverRef = useRef(null); + const cellRef = useRef(null); + const inputRef = useRef(null); + + const [value, setValue] = useState(row.row.textArray); + const [open, setOpen] = useFlag(); + + const onChangeValue = useCallback((value: string[] | null) => { + setValue(value); + if (inputRef.current) { + inputRef.current.value = ''; + } + }, []); + + const error = !!(value && value.length > 10); + + useEffect(() => setOpen.set(error), [error]); + + useHover({ + isActive: error, + refs: [popoverRef, cellRef], + onHover: setOpen.on, + onBlur: setOpen.off, + hoverDelay: 500, + blurDelay: 300, + }); + + useClickOutside({ + isActive: open, + handler: setOpen.off, + ignoreClicksInsideRefs: [popoverRef, cellRef], + }); + + return ( + <> + value?.join(', ')} + size="m" + status={error ? 'alert' : undefined} + indicator={error ? 'alert' : undefined} + ref={cellRef} + inputRef={inputRef} + /> + + + {(animate) => { + return ( + + + + ); + }} + + + ); +}; + +const columns: TableColumn[] = [ + { + title: 'Колонка', + accessor: 'col', + minWidth: 150, + }, + { + title: 'Редактируемый массив', + accessor: 'textArray', + renderCell: CellTypeTextArray, + minWidth: 200, + }, + { + title: 'Колонка', + accessor: 'col2', + minWidth: 150, + }, +]; + +export const TextFieldCellExampleAlertMessage = () => ( + +
+ +); +``` + + + +# Свойства + +| Свойство | Тип | По умолчанию | Описание | +| ----------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | ------------ | ------------------------------------------------ | +| [`size?`](##LIBS.LIB.STAND.TAB/lib:uikit/stand:components-textfield-canary/tab:dev/hash:размер) | `'s' \| 'm'` | `'m'` | Размер ячейки | +| `lineClamp?` | `number` | - | Ограничение количества строк текста | +| `readModeRender?` | `(value?: TextFieldPropValue) => React.ReactNode` | - | Функция для отображения значения в режиме чтения | +| [`level?`](##LIBS.LIB.STAND.TAB/lib:table/stand:components-datacell-stable/tab:dev/hash:уровень-вложенности) | `number` | - | Уровень вложенности ячейки | +| [`indicator?`](##LIBS.LIB.STAND.TAB/lib:table/stand:components-datacell-stable/tab:dev/hash:индикатор) | `'alert' \| 'warning'` | - | Индикатор состояния | +| `truncate?` | `boolean` | - | Обрезка текста многоточием | +| [`value?`](##LIBS.LIB.STAND.TAB/lib:uikit/stand:components-textfield-canary/tab:dev/hash:значение) | `TextFieldPropValue` | - | Значение поля ввода | +| `onChange?` | `TextFieldPropOnChange` | - | Обработчик изменения значения | +| `defaultValue?` | `TextFieldPropValue` | - | Значение по умолчанию | +| `id?` | `string` | - | Идентификатор поля | +| `name?` | `string` | - | Имя поля | +| `disabled?` | `boolean` | - | Флаг отключения поля | +| `maxLength?` | `number` | - | Максимальная длина текста | +| `minLength?` | `number` | - | Минимальная длина текста | +| [`placeholder?`](##LIBS.LIB.STAND.TAB/lib:uikit/stand:components-textfield-canary/tab:dev/hash:подсказка) | `string` | - | Подсказка для ввода | +| [`leftSide?`](##LIBS.LIB.STAND.TAB/lib:uikit/stand:components-textfield-canary/tab:dev/hash:дополнительная-информация) | `React.ReactNode` | - | Элемент слева внутри поля | +| [`rightSide?`](##LIBS.LIB.STAND.TAB/lib:uikit/stand:components-textfield-canary/tab:dev/hash:дополнительная-информация) | `React.ReactNode` | - | Элемент справа внутри поля | +| [`clearButton?`](##LIBS.LIB.STAND.TAB/lib:uikit/stand:components-textfield-canary/tab:dev/hash:кнопка-для-очистки-поля) | `boolean` | - | Флаг отображения кнопки очистки | +| `iconClear?` | `IconComponent` | - | Иконка для кнопки очистки | +| `iconSize?` | `'s' \| 'm'` | - | Размер иконок | +| `onClear?` | `React.MouseEventHandler` | - | Обработчик очистки поля | +| `inputRef?` | `React.Ref` | - | Ссылка на DOM-элемент input | +| [`type?`](#тип) | `'text' \| 'textarea' \| 'textarray' \| 'password' \| 'number'` | `'text'` | Тип поля ввода | +| `max?` | `number` | - | Максимальное значение | +| `min?` | `number` | - | Минимальное значение | +| [`step?`](##LIBS.LIB.STAND.TAB/lib:uikit/stand:components-textfield-canary/tab:dev/hash:шаг--значения-и-диапазон) | `number` | - | Шаг изменения значения | +| `incrementButtons?` | `boolean` | - | Флаг отображения кнопок инкремента | +| `iconShowPassword?` | `IconComponent` | - | Иконка показа пароля | +| `iconHidePassword?` | `IconComponent` | - | Иконка скрытия пароля | +| [`resize?`](##LIBS.LIB.STAND.TAB/lib:uikit/stand:components-textfield-canary/tab:dev/hash:изменение-высоты) | `boolean \| 'auto'` | - | Возможность изменения размера | +| [`minRows?`](##LIBS.LIB.STAND.TAB/lib:uikit/stand:components-textfield-canary/tab:dev/hash:автоматическая-высота) | `number` | - | Минимальное количество строк | +| [`maxRows?`](##LIBS.LIB.STAND.TAB/lib:uikit/stand:components-textfield-canary/tab:dev/hash:автоматическая-высота) | `number` | - | Максимальное количество строк | +| [`rows?`](##LIBS.LIB.STAND.TAB/lib:uikit/stand:components-textfield-canary/tab:dev/hash:поле-в-несколько-строк) | `number` | - | Количество строк | +| `renderValueItem?` | `(item: string) => React.ReactNode` | - | Функция отображения элемента массива | +| `inputValue?` | `string \| null` | - | Значение поля ввода для массива | +| `onInputChange?` | `((value: string \| null) => void)` | - | Обработчик изменения значения ввода | +| `className?` | `string` | - | Дополнительный CSS-класс. | diff --git a/src/components/TextFieldCell/__stand__/TextFieldCell.stand.mdx b/src/components/TextFieldCell/__stand__/TextFieldCell.stand.mdx new file mode 100644 index 0000000..cb60a27 --- /dev/null +++ b/src/components/TextFieldCell/__stand__/TextFieldCell.stand.mdx @@ -0,0 +1,3 @@ +```tsx +import { TextFieldCell } from '@consta/table/TextFieldCell'; +``` diff --git a/src/components/TextFieldCell/__stand__/TextFieldCell.stand.tsx b/src/components/TextFieldCell/__stand__/TextFieldCell.stand.tsx new file mode 100644 index 0000000..7aafb49 --- /dev/null +++ b/src/components/TextFieldCell/__stand__/TextFieldCell.stand.tsx @@ -0,0 +1,12 @@ +import { createStand } from '##/stand/standConfig'; + +export default createStand({ + title: 'TextFieldCell', + id: 'TextFieldCell', + group: 'components', + description: 'Компонент текстового поля для таблицы', + version: '0.8.0', + status: 'stable', + alias: ['ячейка', 'input', 'текстовое поле'], + order: 10, +}); diff --git a/src/components/TextFieldCell/__stand__/TextFieldCell.variants.tsx b/src/components/TextFieldCell/__stand__/TextFieldCell.variants.tsx new file mode 100644 index 0000000..d2df6e8 --- /dev/null +++ b/src/components/TextFieldCell/__stand__/TextFieldCell.variants.tsx @@ -0,0 +1,194 @@ +import { IconPhoto } from '@consta/icons/IconPhoto'; +import { useBoolean, useNumber, useSelect, useText } from '@consta/stand'; +import React, { useCallback, useState } from 'react'; + +import { Table, TableColumn, TableRenderCell } from '##/components/Table'; + +import { TextFieldCell } from '..'; + +type Row = { data: string }; + +const rows: Row[] = [{ data: 'Двойной клик' }]; + +const resizeMap = { + true: true, + false: false, + auto: 'auto', +} as const; + +const getStep = ( + type: string | undefined, + withStepArray: boolean, + step: number | undefined, +) => { + if (type !== 'number') { + return undefined; + } + + if (withStepArray) { + return [10, 50, 100]; + } + + return step; +}; + +const RenderCell: TableRenderCell = (row) => { + const [value, setValue] = useState(row.row.data); + const [valueArray, setValueArray] = useState([row.row.data]); + const [inputValue, setInputValue] = useState(null); + const onChangeValueArray = useCallback((value: string[] | null) => { + setValueArray(value); + setInputValue(null); + }, []); + + const size = useSelect('size', ['m', 's'], 'm'); + + const type = useSelect( + 'type', + ['text', 'number', 'password', 'textarea', 'textarray'], + 'text', + ); + const resize = + useSelect( + 'resize', + ['false', 'true', 'auto'], + 'false', + type === 'textarea', + ) || 'false'; + + const placeholder = useText('placeholder', 'placeholder'); + const rows = useNumber('rows', 3, type === 'textarea' && resize !== 'auto'); + const lineClamp = useNumber('lineClamp', 0); + const minRows = useNumber( + 'minRows', + 1, + type === 'textarea' && resize === 'auto', + ); + const maxRows = useNumber( + 'maxRows', + 5, + type === 'textarea' && resize === 'auto', + ); + + const step = useNumber('step', 1, type === 'number'); + const withStepArray = useBoolean('withStepArray', false, type === 'number'); + const incrementButtons = useBoolean( + 'incrementButtons', + true, + type === 'number', + ); + const min = useNumber('min', 0, type === 'number'); + const max = useNumber('max', 150, type === 'number'); + + const disabled = useBoolean('disabled', false); + const clearButton = useBoolean('clearButton', true); + const maxLength = useNumber('maxLength', 1000, type !== 'number'); + + const leftSideType = useSelect('leftSideType', ['icon', 'text']); + const leftSideText = useText('leftSideText', 'from'); + const rightSideType = useSelect('rightSideType', ['icon', 'text']); + const rightSideText = useText('rightSideText', 'm²'); + const level = useNumber('level', 0); + const status = useSelect('status', ['alert', 'warning']); + + const leftSideSelect = { + text: leftSideText, + icon: IconPhoto, + }; + + const rightSideSelect = { + text: rightSideText, + icon: IconPhoto, + }; + + const leftSide = leftSideType && leftSideSelect[leftSideType]; + const rightSide = rightSideType && rightSideSelect[rightSideType]; + + const indicator = useSelect('indicator', ['alert', 'warning']); + + const props = { + size, + placeholder, + type, + disabled, + clearButton, + maxLength, + leftSide, + rightSide, + level, + lineClamp, + status, + indicator, + }; + + if (type === 'textarray') { + return ( + value?.join(', ')} + onInputChange={setInputValue} + inputValue={inputValue} + /> + ); + } + + if (type === 'textarea') { + return ( + + ); + } + + if (type === 'number') { + return ( + + ); + } + + return ( + + ); +}; + +const Variants = () => { + const columns: TableColumn[] = [ + { title: 'Изменяемые данные', accessor: 'data', renderCell: RenderCell }, + ]; + + return ( +
+
+ + ); +}; + +export default Variants; diff --git a/src/components/TextFieldCell/__stand__/examples/TextFieldCellExampleAlertMessage/TextFieldCellExampleAlertMessage.tsx b/src/components/TextFieldCell/__stand__/examples/TextFieldCellExampleAlertMessage/TextFieldCellExampleAlertMessage.tsx new file mode 100644 index 0000000..7f24628 --- /dev/null +++ b/src/components/TextFieldCell/__stand__/examples/TextFieldCellExampleAlertMessage/TextFieldCellExampleAlertMessage.tsx @@ -0,0 +1,139 @@ +import { Example } from '@consta/stand'; +import { Informer } from '@consta/uikit/Informer'; +import { + animateTimeout, + cnMixPopoverAnimate, +} from '@consta/uikit/MixPopoverAnimate'; +import { Popover } from '@consta/uikit/Popover'; +import { useClickOutside } from '@consta/uikit/useClickOutside'; +import { useFlag } from '@consta/uikit/useFlag'; +import { useHover } from '@consta/uikit/useHover'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { Transition } from 'react-transition-group'; + +import { Table, TableColumn, TableRenderCell } from '##/components/Table'; +import { TextFieldCell } from '##/components/TextFieldCell'; + +type Row = { + col: string; + col2: string; + textArray: string[]; +}; + +const rows: Row[] = [ + { + col: 'value1', + textArray: Array.from({ length: 11 }, (_, i) => `элемент-${i + 1}`), + col2: 'value2', + }, +]; + +const CellTypeTextArray: TableRenderCell = (row) => { + const popoverRef = useRef(null); + const cellRef = useRef(null); + const inputRef = useRef(null); + + const [value, setValue] = useState(row.row.textArray); + const [open, setOpen] = useFlag(); + + const onChangeValue = useCallback((value: string[] | null) => { + setValue(value); + if (inputRef.current) { + inputRef.current.value = ''; + } + }, []); + + const error = !!(value && value.length > 10); + + useEffect(() => setOpen.set(error), [error]); + + useHover({ + isActive: error, + refs: [popoverRef, cellRef], + onHover: setOpen.on, + onBlur: setOpen.off, + hoverDelay: 500, + blurDelay: 300, + }); + + useClickOutside({ + isActive: open, + handler: setOpen.off, + ignoreClicksInsideRefs: [popoverRef, cellRef], + }); + + return ( + <> + value?.join(', ')} + size="m" + status={error ? 'alert' : undefined} + indicator={error ? 'alert' : undefined} + ref={cellRef} + inputRef={inputRef} + /> + + + {(animate) => { + return ( + + + + ); + }} + + + ); +}; + +const columns: TableColumn[] = [ + { + title: 'Колонка', + accessor: 'col', + minWidth: 150, + }, + { + title: 'Редактируемый массив', + accessor: 'textArray', + renderCell: CellTypeTextArray, + minWidth: 200, + }, + { + title: 'Колонка', + accessor: 'col2', + minWidth: 150, + }, +]; + +export const TextFieldCellExampleAlertMessage = () => ( + +
+ +); diff --git a/src/components/TextFieldCell/__stand__/examples/TextFieldCellExampleTypes/TextFieldCellExampleTypes.tsx b/src/components/TextFieldCell/__stand__/examples/TextFieldCellExampleTypes/TextFieldCellExampleTypes.tsx new file mode 100644 index 0000000..8a205ee --- /dev/null +++ b/src/components/TextFieldCell/__stand__/examples/TextFieldCellExampleTypes/TextFieldCellExampleTypes.tsx @@ -0,0 +1,144 @@ +import { Example } from '@consta/stand'; +import React, { useCallback, useState } from 'react'; + +import { Table, TableColumn, TableRenderCell } from '##/components/Table'; +import { TextFieldCell } from '##/components/TextFieldCell'; + +type Row = { + text: string; + textarea: string; + textareaAutosize: string; + number: string; + textArray: string[]; +}; + +const rows: Row[] = [ + { + text: 'value1', + textarea: 'value2', + textareaAutosize: 'value3', + number: 'value4', + textArray: ['value5', 'value6'], + }, +]; + +const CellTypeText: TableRenderCell = (row) => { + const [value, setValue] = useState(row.row.text); + + return ( + + ); +}; + +const CellTypeTextArea: TableRenderCell = (row) => { + const [value, setValue] = useState(row.row.textarea); + + return ( + + ); +}; + +const CellTypeTextAreaAutosize: TableRenderCell = (row) => { + const [value, setValue] = useState(row.row.textareaAutosize); + + return ( + + ); +}; + +const CellTypeNumber: TableRenderCell = (row) => { + const [value, setValue] = useState(row.row.number); + + return ( + + ); +}; + +const CellTypeTextArray: TableRenderCell = (row) => { + const [value, setValue] = useState(row.row.textArray); + + const [inputValue, setInputValue] = useState(null); + const onChangeValueArray = useCallback((value: string[] | null) => { + setValue(value); + setInputValue(null); + }, []); + + return ( + value?.join(', ')} + size="m" + /> + ); +}; + +const columns: TableColumn[] = [ + { + title: 'TextArray', + accessor: 'textArray', + renderCell: CellTypeTextArray, + minWidth: 200, + }, + { + title: 'Text', + accessor: 'text', + renderCell: CellTypeText, + minWidth: 200, + }, + + { + title: 'Textarea', + accessor: 'textarea', + renderCell: CellTypeTextArea, + minWidth: 200, + }, + { + title: 'Textarea autosize', + accessor: 'textareaAutosize', + renderCell: CellTypeTextAreaAutosize, + minWidth: 200, + }, + + { + title: 'Number', + accessor: 'number', + renderCell: CellTypeNumber, + minWidth: 200, + }, +]; + +export const TextFieldCellExampleTypes = () => ( + +
+ +); diff --git a/src/components/TextFieldCell/index.ts b/src/components/TextFieldCell/index.ts new file mode 100644 index 0000000..1bc66df --- /dev/null +++ b/src/components/TextFieldCell/index.ts @@ -0,0 +1 @@ +export * from './TextFieldCell'; diff --git a/src/components/Toolbar/Toolbar.css b/src/components/Toolbar/Toolbar.css index bf5a2bb..22ec818 100644 --- a/src/components/Toolbar/Toolbar.css +++ b/src/components/Toolbar/Toolbar.css @@ -1,20 +1,23 @@ .ct--Toolbar { --bar-default-border-radius: calc(var(--control-radius) * 2); --bar-default-border: var(--control-border-width) solid var(--color-bg-border); - background: var(--color-bg-stripe); - /* border: var(--control-border-width) solid var(--color-bg-border); */ + /* border: var(--control-border-width) solid var(--color-bg-border); */ &_form { &_default { border-radius: var(--bar-default-border-radius); } + &_defaultBrick { - border-radius: var(--bar-default-border-radius) + border-radius: + var(--bar-default-border-radius) var(--bar-default-border-radius) 0 0; } + &_brickDefault { - border-radius: 0 0 var(--bar-default-border-radius) + border-radius: + 0 0 var(--bar-default-border-radius) var(--bar-default-border-radius); } } @@ -23,9 +26,11 @@ &_all { border: var(--bar-default-border); } + &_top { border-top: var(--bar-default-border); } + &_bottom { border-bottom: var(--bar-default-border); } diff --git a/src/components/Toolbar/ToolbarDivider/ToolbarDivider.css b/src/components/Toolbar/ToolbarDivider/ToolbarDivider.css index bfb2f4a..801bf0a 100644 --- a/src/components/Toolbar/ToolbarDivider/ToolbarDivider.css +++ b/src/components/Toolbar/ToolbarDivider/ToolbarDivider.css @@ -1,5 +1,5 @@ .ct--ToolbarDivider { - background-color: var(--color-bg-border); width: calc(var(--control-border-width) * 2); height: var(--toolbar-divider-height); + background-color: var(--color-bg-border); } diff --git a/src/utils/object/clearUndefined.ts b/src/utils/object/clearUndefined.ts new file mode 100644 index 0000000..006f474 --- /dev/null +++ b/src/utils/object/clearUndefined.ts @@ -0,0 +1,11 @@ +export const clearUndefined = >( + obj: T, +): T => { + const result = { ...obj }; + for (const key in result) { + if (result[key] === undefined) { + delete result[key]; + } + } + return result as T; +}; diff --git a/src/utils/object/index.ts b/src/utils/object/index.ts index 4fc8966..24653bc 100644 --- a/src/utils/object/index.ts +++ b/src/utils/object/index.ts @@ -1,2 +1,3 @@ export * from './isEmpty'; export * from './isEq'; +export * from './clearUndefined';