diff --git a/app/src/common/docs/DocExample.tsx b/app/src/common/docs/DocExample.tsx index 8d2f96aada..0e85e699a3 100644 --- a/app/src/common/docs/DocExample.tsx +++ b/app/src/common/docs/DocExample.tsx @@ -8,7 +8,7 @@ import { CodesandboxLink } from './CodesandboxLink'; import { Code } from './Code'; import cx from 'classnames'; import { docExampleLoader } from './docExampleLoader'; -import { TTheme } from '../../data'; +import { ThemeId } from '../../data'; import { LinkButton, FlexSpacer } from '@epam/uui'; import { ReactComponent as PreviewIcon } from '@epam/assets/icons/common/media-fullscreen-12.svg'; import { getCurrentTheme } from '../../helpers'; @@ -113,7 +113,7 @@ export class DocExample extends React.Component void; }; @@ -38,6 +38,6 @@ export function SkinModeToggler(props: TSkinModeTogglerProps) { } } -function isSkinSupportedInTheme(theme: TTheme): boolean { +function isSkinSupportedInTheme(theme: ThemeId): boolean { return ['electric', 'loveship', 'loveship_dark', 'promo'].includes(theme); } diff --git a/app/src/common/docs/baseDocBlock/components/tabsNav.tsx b/app/src/common/docs/baseDocBlock/components/tabsNav.tsx index cb59b74470..ebe9c89b67 100644 --- a/app/src/common/docs/baseDocBlock/components/tabsNav.tsx +++ b/app/src/common/docs/baseDocBlock/components/tabsNav.tsx @@ -1,7 +1,8 @@ import React, { useEffect, useState } from 'react'; import { Button, FlexRow, FlexSpacer, TabButton } from '@epam/uui'; import { TMode } from '../../docsConstants'; -import { ReactComponent as NavigationShowOutlineIcon } from '@epam/assets/icons/navigation-show-outline.svg'; import css from './tabsNav.module.scss'; +import { ReactComponent as NavigationShowOutlineIcon } from '@epam/assets/icons/navigation-show-outline.svg'; +import css from './tabsNav.module.scss'; type TTabsNavProps = { mode: TMode; @@ -17,7 +18,7 @@ type TabType = { }; export function TabsNav(props: TTabsNavProps) { - const { mode, onChangeMode, supportedModes } = props; + const { mode, onChangeMode, supportedModes, renderSkinSwitcher } = props; const [pageWidth, setPageWidth] = useState(window.innerWidth); useEffect(() => { @@ -28,7 +29,7 @@ export function TabsNav(props: TTabsNavProps) { window.removeEventListener('resize', handleResize); }; }, []); - + const allTabs: Partial> = { [TMode.doc]: { caption: 'Documentation', @@ -79,6 +80,7 @@ export function TabsNav(props: TTabsNavProps) { }, []) } + { renderSkinSwitcher() } ); } diff --git a/app/src/common/docs/baseDocBlock/tabs/propExplorerTab.tsx b/app/src/common/docs/baseDocBlock/tabs/propExplorerTab.tsx index 485c925a7b..7093087a34 100644 --- a/app/src/common/docs/baseDocBlock/tabs/propExplorerTab.tsx +++ b/app/src/common/docs/baseDocBlock/tabs/propExplorerTab.tsx @@ -1,12 +1,12 @@ import * as React from 'react'; import { TDocConfig } from '@epam/uui-docs'; import { ComponentEditorWrapper } from '../../properyEditor/PropertyEditor'; -import { TTheme } from '../../../../data'; +import { ThemeId } from '../../../../data'; type TPropExplorerTabProps = { isSkin: boolean; title: string; - theme: TTheme; + theme: ThemeId; config: TDocConfig | undefined; onOpenDocTab: () => void; }; diff --git a/app/src/common/docs/baseDocBlock/utils/queryHelpers.ts b/app/src/common/docs/baseDocBlock/utils/queryHelpers.ts index 79c8122c16..ef01eb267a 100644 --- a/app/src/common/docs/baseDocBlock/utils/queryHelpers.ts +++ b/app/src/common/docs/baseDocBlock/utils/queryHelpers.ts @@ -1,7 +1,7 @@ import { getCurrentTheme, getQuery } from '../../../../helpers'; import { DEFAULT_MODE, TMode } from '../../docsConstants'; import { svc } from '../../../../services'; -import { TTheme } from '../../../../data'; +import { ThemeId } from '../../../../data'; export class QueryHelpers { static isSkin(): boolean { @@ -12,7 +12,7 @@ export class QueryHelpers { return getQuery('mode') || DEFAULT_MODE; } - static getTheme(): TTheme { + static getTheme(): ThemeId { return getCurrentTheme(); } @@ -26,10 +26,10 @@ export class QueryHelpers { QueryHelpers.handleNav({ mode }); } - static handleNav = (params: { id?: string; mode?: TMode, isSkin?: boolean, theme?: TTheme }) => { + static handleNav = (params: { id?: string; mode?: TMode, isSkin?: boolean, theme?: ThemeId }) => { const mode: TMode = params.mode ? params.mode : QueryHelpers.getMode(); const isSkin: boolean = params.isSkin ?? QueryHelpers.isSkin(); - const theme: TTheme = params.theme ? params.theme : QueryHelpers.getTheme(); + const theme: ThemeId = params.theme ? params.theme : QueryHelpers.getTheme(); svc.uuiRouter.redirect({ pathname: '/documents', diff --git a/app/src/common/docs/properyEditor/PropertyEditor.tsx b/app/src/common/docs/properyEditor/PropertyEditor.tsx index 9e230aa3c3..df7781112c 100644 --- a/app/src/common/docs/properyEditor/PropertyEditor.tsx +++ b/app/src/common/docs/properyEditor/PropertyEditor.tsx @@ -20,10 +20,10 @@ import { } from './propDocUtils'; import { useQuery } from '../../../helpers'; import { buildPreviewRef } from '../../../preview/utils/previewLinkUtils'; -import { TTheme } from '../../../data'; +import { ThemeId } from '../../../data'; export function ComponentEditorWrapper(props: { - theme: TTheme, + theme: ThemeId, title: string; isSkin: boolean; config: TDocConfig; @@ -69,7 +69,7 @@ interface ComponentEditorProps { skin: TSkin; title: string; isSkin: boolean; - theme: TTheme; + theme: ThemeId; componentId: string; isLoaded: boolean; onRedirectBackToDocs: () => void; diff --git a/app/src/common/docs/properyEditor/utils.ts b/app/src/common/docs/properyEditor/utils.ts index d86806fbee..6b6248f485 100644 --- a/app/src/common/docs/properyEditor/utils.ts +++ b/app/src/common/docs/properyEditor/utils.ts @@ -3,11 +3,11 @@ import { useUuiContext } from '@epam/uui-core'; import { useMemo } from 'react'; import { loadDocsGenType } from '../../apiReference/dataHooks'; import { getAllIcons } from '../../../documents/iconListHelpers'; -import { AppContext, BuiltInTheme, TTheme } from '../../../data'; +import { AppContext, BuiltInTheme, ThemeId } from '../../../data'; import { CustomThemeManifest } from '../../../data/customThemes'; import { useAppThemeContext } from '../../../helpers/appTheme'; -export function getSkin(theme: TTheme, isSkin: boolean): TSkin { +export function getSkin(theme: ThemeId, isSkin: boolean): TSkin { if (!isSkin) return TSkin.UUI; switch (theme) { @@ -39,7 +39,7 @@ export function useDocBuilderGenCtx(propsOverride: TPropEditorTypeOverride[TType }, [propsOverride, uuiCtx.api.demo, uuiCtx.uuiNotifications]); } -export function usePropEditorTypeOverride(themeId: TTheme, typeRef: TTypeRef | undefined): TPropEditorTypeOverride[TTypeRef] | undefined { +export function usePropEditorTypeOverride(themeId: ThemeId, typeRef: TTypeRef | undefined): TPropEditorTypeOverride[TTypeRef] | undefined { const { themesById } = useAppThemeContext(); if (typeRef && themesById) { const themeDetails = (themesById[themeId] as CustomThemeManifest); diff --git a/app/src/data/appContext.ts b/app/src/data/appContext.ts index 25033923d5..644e950321 100644 --- a/app/src/data/appContext.ts +++ b/app/src/data/appContext.ts @@ -1,17 +1,17 @@ -import { ThemeBaseParams, builtInThemes, TTheme } from './themes'; +import { ThemeBaseParams, builtInThemes, ThemeId } from './themes'; import { CustomThemeManifest, loadCustomThemes } from './customThemes'; import { DocItem } from '../documents/structure'; export interface AppContext { - themes: TTheme[], - themesById: Record, + themes: ThemeId[], + themesById: Record, docsMenuStructure: DocItem[], } export async function getThemeContext() { const customThemesArr = await loadCustomThemes(); const allThemes = [...builtInThemes, ...customThemesArr]; - const themesById = allThemes.reduce>((acc, t) => { + const themesById = allThemes.reduce>((acc, t) => { acc[t.id] = t; return acc; }, {}); diff --git a/app/src/data/customThemes.ts b/app/src/data/customThemes.ts index d211a089e9..de1c3993ba 100644 --- a/app/src/data/customThemes.ts +++ b/app/src/data/customThemes.ts @@ -1,30 +1,46 @@ import { TPropEditorTypeOverride } from '@epam/uui-docs'; -import { TTheme } from './themes'; +import { ThemeId } from './themes'; +import { svc } from '../services'; const THEME_MANIFEST_FILE = 'theme-manifest.json'; +const KEY_CUSTOM_THEMES = 'uui-custom-themes'; export interface CustomThemeManifest { - id: TTheme; + id: ThemeId; name: string; css: string[]; + path: string; settings: null | object; propsOverride?: TPropEditorTypeOverride; } -interface TUuiCustomThemesLsItem { - customThemes: string[], -} - -function getCustomThemesConfigFromLs() { - const KEY_CUSTOM_THEMES = 'uui-custom-themes'; +function getCustomThemesConfig(): string[] { + getCustomThemePathFromUrl(); const customThemes = localStorage.getItem(KEY_CUSTOM_THEMES); if (typeof customThemes === 'string') { try { - return JSON.parse(customThemes) as TUuiCustomThemesLsItem; + return JSON.parse(customThemes).customThemes; } catch (err) { console.error(`Unable to parse item from localStorage (key="${KEY_CUSTOM_THEMES}")`, err); } } + return []; +} + +function getCustomThemePathFromUrl() { + const themePath = svc.uuiRouter.getCurrentLink().query?.themePath; + if (themePath) { + const customThemes = JSON.parse(localStorage.getItem(KEY_CUSTOM_THEMES))?.customThemes || []; + if (!customThemes.includes(themePath)) { + customThemes.push(themePath); + localStorage.setItem( + 'uui-custom-themes', + JSON.stringify({ + customThemes: customThemes, + }), + ); + } + } } let cache: Promise; @@ -35,11 +51,15 @@ export async function loadCustomThemes(): Promise { return cache; } async function loadCustomThemesInternal() { - const { customThemes = [] } = getCustomThemesConfigFromLs() || {}; + const customThemes = getCustomThemesConfig(); const ctManifestArr: CustomThemeManifest[] = []; if (customThemes.length > 0) { const ctManifestArrLoaded = await Promise.all( customThemes.map(async (themeUrl) => { + if (!themeUrl) { + return; + } + const themeManifestUrl = `${themeUrl}/${THEME_MANIFEST_FILE}`; return fetch(themeManifestUrl) .then(async (r) => { @@ -47,7 +67,7 @@ async function loadCustomThemesInternal() { const { id, name, css, settings, propsOverride } = tmJson; const loadedSettings = settings ? await loadSettings(convertRelUrlToAbs(settings, themeUrl)) : null; await loadCssArr(css.map((cssRel) => convertRelUrlToAbs(cssRel, themeUrl))); - return { id, name, css, settings: loadedSettings, propsOverride }; + return { id, name, path: themeUrl, css, settings: loadedSettings, propsOverride }; }) .catch((err) => { console.error(`Unable to load custom theme from "${themeManifestUrl}"`, err); diff --git a/app/src/data/themes.ts b/app/src/data/themes.ts index 7936e34fb3..67a9921132 100644 --- a/app/src/data/themes.ts +++ b/app/src/data/themes.ts @@ -9,10 +9,10 @@ export enum BuiltInTheme { /* No restrictions on custom theme id - it can be any string */ type CustomTheme = string; -export type TTheme = BuiltInTheme | CustomTheme; +export type ThemeId = BuiltInTheme | CustomTheme; export interface ThemeBaseParams { - id: TTheme; + id: ThemeId; name: string; } diff --git a/app/src/demo/tables/filteredTable/columns.tsx b/app/src/demo/tables/filteredTable/columns.tsx index 543a79661d..afdf223e3a 100644 --- a/app/src/demo/tables/filteredTable/columns.tsx +++ b/app/src/demo/tables/filteredTable/columns.tsx @@ -24,6 +24,7 @@ export const personColumns: DataColumnProps[] = [ ), grow: 0, width: 100, + minWidth: 90, isSortable: true, }, { diff --git a/app/src/demo/tables/masterDetailedTable/columns.tsx b/app/src/demo/tables/masterDetailedTable/columns.tsx index ed7a34e516..e8aaf2e316 100644 --- a/app/src/demo/tables/masterDetailedTable/columns.tsx +++ b/app/src/demo/tables/masterDetailedTable/columns.tsx @@ -26,6 +26,7 @@ export const personColumns: DataColumnProps ), width: 140, + minWidth: 90, isSortable: true, isFilterActive: (f) => !!f.profileStatusId, }, { diff --git a/app/src/docExample/docExamplePage.tsx b/app/src/docExample/docExamplePage.tsx index 058ab6c4c3..0be56589b5 100644 --- a/app/src/docExample/docExamplePage.tsx +++ b/app/src/docExample/docExamplePage.tsx @@ -3,12 +3,12 @@ import { useQuery } from '../helpers'; import { docExampleLoader } from '../common/docs/docExampleLoader'; import { DocExampleContent } from './docExampleContent'; import { usePlayWrightInterface } from '../preview/hooks/usePlayWrightInterface'; -import { BuiltInTheme, TTheme } from '../data'; +import { BuiltInTheme, ThemeId } from '../data'; import { svc } from '../services'; interface DocExamplePageParams { examplePath: string; - theme: TTheme; + theme: ThemeId; } export function DocExamplePage() { diff --git a/app/src/docs/Tree.doc.tsx b/app/src/docs/Tree.doc.tsx new file mode 100644 index 0000000000..6fcea1baa1 --- /dev/null +++ b/app/src/docs/Tree.doc.tsx @@ -0,0 +1,17 @@ +import * as React from 'react'; +import { + EditableDocContent, DocExample, BaseDocsBlock, +} from '../common'; + +export class TreeDoc extends BaseDocsBlock { + title = 'Tree'; + renderContent() { + return ( + <> + + {this.renderSectionTitle('Examples')} + + + ); + } +} diff --git a/app/src/docs/_examples/dataSources/CustomHierarchicalList.example.tsx b/app/src/docs/_examples/dataSources/CustomHierarchicalList.example.tsx new file mode 100644 index 0000000000..ac608371e7 --- /dev/null +++ b/app/src/docs/_examples/dataSources/CustomHierarchicalList.example.tsx @@ -0,0 +1,172 @@ +import React, { useCallback, useState } from 'react'; +import { + DataSourceState, + useArrayDataSource, + uuiMarkers, + uuiElement, + uuiMod, +} from '@epam/uui-core'; +import { ReactComponent as FoldingArrow } from '@epam/assets/icons/navigation-chevron_down-outline.svg'; +import css from './CustomHierarchicalList.module.scss'; +import { Text, Panel, VirtualList, TextPlaceholder, IconContainer } from '@epam/uui'; +import classNames from 'classnames'; + +interface Task { + id: number; + parentId?: number; + name: string; + tasksCount?: number; +} + +const tasks: Task[] = [ + { id: 1, name: 'Infrastructure' }, + { id: 101, name: 'Devops', parentId: 1 }, + { id: 102, name: 'Frontend', parentId: 1 }, + { id: 10101, name: 'GIT Repository init', parentId: 101 }, + { id: 10103, name: 'Test instances - Dev, QA, UAT', parentId: 101 }, + { id: 10102, name: 'CI - build code, publish artifacts', parentId: 101 }, + { id: 10104, name: 'API connection & secrets management', parentId: 101 }, + { id: 10105, name: 'Production instance', parentId: 101 }, + { id: 10201, name: 'Init CRA project', parentId: 102 }, + { id: 10202, name: 'Decide and document coding practices and processes', parentId: 102 }, + { id: 301, name: 'Color palette', parentId: 3 }, + { id: 302, name: 'Core color tokens', parentId: 3 }, + { id: 303, name: 'Components tuning', parentId: 3 }, + { id: 304, name: 'Custom components modifiers', parentId: 3 }, + { id: 401, name: 'Accordion (foldable panel/section)', parentId: 4 }, + { id: 402, name: 'Alert', parentId: 4 }, + { id: 403, name: 'Attribute/Value section', parentId: 4 }, + { id: 404, name: 'Breadcrumbs', parentId: 4 }, + { id: 405, name: 'Dashboard Cards Elements', parentId: 4 }, + { id: 406, name: 'Forms headers/sub-headers', parentId: 4 }, + { id: 407, name: 'Icons', parentId: 4 }, + { id: 408, name: 'Masked input', parentId: 4 }, + { id: 409, name: 'Master-detail screen', parentId: 4 }, + { id: 410, name: 'Common Modal windows (e.g. confirmation)', parentId: 4 }, + { id: 411, name: 'Stepper', parentId: 4 }, + { id: 412, name: 'In-form Tables', parentId: 4 }, + { id: 413, name: 'User card', parentId: 4 }, + { id: 414, name: 'Top-level navigation', parentId: 4 }, + { id: 501, name: 'Page layout components', parentId: 5 }, + { id: 502, name: 'Master-detail', parentId: 5 }, + { id: 503, name: 'Full-screen table', parentId: 5 }, + { id: 504, name: 'Full-screen form', parentId: 5 }, + { id: 505, name: 'Dashboard', parentId: 5 }, + { id: 506, name: 'Catalog', parentId: 5 }, + { id: 601, name: 'Home', parentId: 6 }, + { id: 602, name: 'Catalog', parentId: 6 }, + { id: 603, name: 'Product Details', parentId: 6 }, + { id: 604, name: 'Favorites', parentId: 6 }, + { id: 605, name: 'Comparison', parentId: 6 }, + { id: 606, name: 'Check-out form', parentId: 6 }, + { id: 60701, name: 'Products List', parentId: 607 }, + { id: 60702, name: 'Product Form', parentId: 607 }, + { id: 60703, name: 'Sales report', parentId: 607 }, + { id: 60704, name: 'Marketing dashboard', parentId: 607 }, + { id: 60705, name: 'Categories list editor', parentId: 607 }, + { id: 201, name: 'Authentication', parentId: 2 }, + { id: 202, name: 'Integration with API', parentId: 2 }, + { id: 203, name: 'Routing', parentId: 2 }, + { id: 204, name: 'Localization', parentId: 2 }, + { id: 2, name: 'Shared services' }, + { id: 3, name: 'UUI Customization' }, + { id: 4, name: 'Shared Components' }, + { id: 5, name: 'Pages Template Components' }, + { id: 6, name: 'Pages' }, + { id: 607, name: 'Admin area', parentId: 6 }, +]; + +export default function CitiesTable() { + const [listState, setListState] = useState({ + visibleCount: 20, // how many items to load initially? + }); + + // Create DataSource instance for your table. + // For more details go to the DataSources example + const tasksDs = useArrayDataSource( + { + items: tasks, + getParentId: ({ parentId }) => parentId, + }, + [], + ); + + // Create View according to your tableState and options + const view = tasksDs.useView(listState, setListState, { + getRowOptions: useCallback( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + (item) => ({ isSelectable: true }), // can set row options here + [], + ), + }); + const rows = view.getVisibleRows().map((row) => { + let content: React.ReactNode; + if (row.isLoading) { + content = ; + } else { + content = ( + + {row.value.name} + + ); + } + + const getIndent = () => (row.indent - 1) * 24; + + return ( +
row.onFold(row)) : (() => row.onSelect(row)) } + > + { + row.indent > 0 && ( +
+ {row.isFoldable && ( + row.onFold(row) } + size={ 18 } + /> + )} +
+ ) + } + {content} +
+ ); + }); + + return ( + + + + ); +} diff --git a/app/src/docs/_examples/dataSources/CustomHierarchicalList.module.scss b/app/src/docs/_examples/dataSources/CustomHierarchicalList.module.scss new file mode 100644 index 0000000000..f7927884ec --- /dev/null +++ b/app/src/docs/_examples/dataSources/CustomHierarchicalList.module.scss @@ -0,0 +1,36 @@ +.row { + display: flex; + flex-direction: row; + min-width: 90px; + margin: 0; + min-height: 30px; + border-bottom: 0 none; + border-inline-start: 3px solid transparent; + cursor: pointer; + user-select: none; + + &:hover, + &:global(.uui-selected) { + > * { + color: var(--uui-primary-50); + } + } + + &:global(.uui-selected) { + border-left: 3px solid var(--uui-primary-50); + } +} + +.folding-arrow { + padding-inline-start: 12px; + width: 18px; +} + +.folding-arrow-icon { + fill: var(--uui-icon); +} + +.container { + height: 300px; + width: 400px; +} diff --git a/app/src/docs/dataSources/Usage.doc.tsx b/app/src/docs/dataSources/Usage.doc.tsx index 18f23c292e..be888d3657 100644 --- a/app/src/docs/dataSources/Usage.doc.tsx +++ b/app/src/docs/dataSources/Usage.doc.tsx @@ -10,6 +10,7 @@ export class DataSourcesUsageDoc extends BaseDocsBlock { + ); } diff --git a/app/src/docs/pickerInput/PickerInput.doc.tsx b/app/src/docs/pickerInput/PickerInput.doc.tsx index aa7f3feb0e..8c32dd6586 100644 --- a/app/src/docs/pickerInput/PickerInput.doc.tsx +++ b/app/src/docs/pickerInput/PickerInput.doc.tsx @@ -22,7 +22,11 @@ export class PickerInputDoc extends BaseDocsBlock { }, doc: (doc: DocBuilder>) => { doc.merge('renderToggler', { examples: renderTogglerExamples }); - doc.merge('getRowOptions', { examples: [{ name: 'Disabled rows', value: () => ({ isDisabled: true, isSelectable: false }) }] }); + doc.merge('getRowOptions', { examples: [ + { name: 'Disabled rows', value: () => ({ isDisabled: true, isSelectable: false }) }, + { name: 'Disabled checkboxes', value: () => ({ isDisabled: true, checkbox: { isVisible: true, isDisabled: true } }) }, + ], + }); doc.merge('size', { defaultValue: '36' }); doc.setDefaultPropExample('valueType', (e) => { return e.value === 'id'; diff --git a/app/src/docs/themes/implementation/useTokensDoc.tsx b/app/src/docs/themes/implementation/useTokensDoc.tsx index 0047cdf31e..b6866886ce 100644 --- a/app/src/docs/themes/implementation/useTokensDoc.tsx +++ b/app/src/docs/themes/implementation/useTokensDoc.tsx @@ -4,7 +4,7 @@ import { import { useThemeTokens } from '../../../sandbox/tokens/palette/hooks/useThemeTokens/useThemeTokens'; import { IThemeVarUI, TLoadThemeTokensParams, TThemeTokenValueType } from '../../../sandbox/tokens/palette/types/types'; import { isGroupCfgWithSubgroups, ITokensDocGroup, ITokensDocItem, TTokensDocGroupCfg, TTokensDocItemCfg } from './types'; -import { TTheme } from '../../../data'; +import { ThemeId } from '../../../data'; const PARAMS: TLoadThemeTokensParams = { filter: { @@ -13,7 +13,7 @@ const PARAMS: TLoadThemeTokensParams = { }, valueType: TThemeTokenValueType.chain, }; -export function useTokensDoc(): { loading: boolean, tokens: ITokensDocGroup[], uuiTheme: TTheme } { +export function useTokensDoc(): { loading: boolean, tokens: ITokensDocGroup[], uuiTheme: ThemeId } { const result = useThemeTokens(PARAMS); const { tokens, diff --git a/app/src/documents/DocumentsPage.tsx b/app/src/documents/DocumentsPage.tsx index a90db5f395..e4a60df4e8 100644 --- a/app/src/documents/DocumentsPage.tsx +++ b/app/src/documents/DocumentsPage.tsx @@ -5,14 +5,14 @@ import { AppHeader, Page } from '../common'; import { useQuery } from '../helpers'; import { codesandboxService } from '../data/service'; import { TMode } from '../common/docs/docsConstants'; -import { AppContext, type TApi, TTheme } from '../data'; +import { AppContext, type TApi, ThemeId } from '../data'; import { DocsSidebar } from '../common/docs/DocsSidebar'; type DocsQuery = { id: string; mode?: TMode; isSkin?: boolean; - theme?: TTheme; + theme?: ThemeId; category?: string; }; diff --git a/app/src/documents/structureComponents.ts b/app/src/documents/structureComponents.ts index 64bc6959f6..21193fd951 100644 --- a/app/src/documents/structureComponents.ts +++ b/app/src/documents/structureComponents.ts @@ -58,6 +58,7 @@ import { } from '../docs'; import { AnchorDoc } from '../docs/anchor/Anchor.doc'; import { RichTextEditorSerializersDoc } from '../docs/RichTextEditorSerializers.doc'; +import { TreeDoc } from '../docs/Tree.doc'; export const componentsStructure = orderBy( [ @@ -114,6 +115,7 @@ export const componentsStructure = orderBy( { id: 'tabButton', name: 'Tab Button', component: TabButtonDoc, parentId: 'components' }, { id: 'tables', name: 'Data Tables', parentId: 'components', tags: ['table'] }, { id: 'tablesOverview', name: 'Overview', component: TablesOverviewDoc, parentId: 'tables', order: 1, tags: ['tables', 'dataTable'] }, + { id: 'tree', name: 'Tree', component: TreeDoc, parentId: 'components', tags: ['tree', 'virtualList', 'dataSources'] }, { id: 'editableTables', name: 'Editable Tables', component: EditableTablesDoc, parentId: 'tables', order: 2, tags: ['tables', 'dataTable'] }, { id: 'advancedTables', name: 'Advanced', component: AdvancedTablesDoc, parentId: 'tables', order: 3, tags: ['tables', 'dataTable'] }, { id: 'useTableState', name: 'useTableState', component: useTableStateDoc, parentId: 'tables', order: 4, tags: ['tables', 'dataTable'] }, diff --git a/app/src/helpers/appTheme.tsx b/app/src/helpers/appTheme.tsx index 3423b6597f..6a9a0a8bbe 100644 --- a/app/src/helpers/appTheme.tsx +++ b/app/src/helpers/appTheme.tsx @@ -1,7 +1,9 @@ import React, { useContext, useEffect, useMemo, useState } from 'react'; import { CustomThemeManifest, loadCustomThemes } from '../data/customThemes'; -import { builtInThemes, ThemeBaseParams, TTheme } from '../data'; -import { applyTheme, changeThemeQueryParam, TAppThemeContext, TThemeConfig, useCurrentTheme } from './appThemeUtils'; +import { builtInThemes, ThemeBaseParams, ThemeId } from '../data'; +import { changeThemeQueryParam, overrideUuiSettings, saveThemeIdToLocalStorage, + setThemeCssClass, TAppThemeContext, ThemesConfig, useCurrentTheme, +} from './appThemeUtils'; import { useUuiContext } from '@epam/uui-core'; const AppThemeContext = React.createContext(null); @@ -11,7 +13,7 @@ export function useAppThemeContext() { export function AppTheme(props: { children: React.ReactNode }) { const { uuiRouter } = useUuiContext(); - const [appliedTheme, setAppliedTheme] = useState(null); + const [appliedTheme, setAppliedTheme] = useState(null); const config = useThemeConfig(); /** * The query parameter "theme" is a single source of truth. @@ -20,27 +22,32 @@ export function AppTheme(props: { children: React.ReactNode }) { const theme = useCurrentTheme(config); useEffect(() => { if (theme && config && appliedTheme !== theme) { - if (!config.themesById[theme]) { - reportUnknownThemeError(theme); - return; - } - - applyTheme(theme, config); - setAppliedTheme(theme); + applyTheme(theme); } }, [appliedTheme, config, theme]); + function applyTheme(newTheme: ThemeId) { + const nextThemeConfig = config.themesById[newTheme]; + + if (!nextThemeConfig) { + reportUnknownThemeError(theme); + return; + } + setThemeCssClass(newTheme); + saveThemeIdToLocalStorage(newTheme); + overrideUuiSettings((nextThemeConfig as CustomThemeManifest).settings); + changeThemeQueryParam(nextThemeConfig, uuiRouter); + + setAppliedTheme(newTheme); + } + const value = useMemo(() => { if (theme && config) { return { ...config, theme, - toggleTheme: (nextTheme: TTheme) => { - if (!config.themesById[nextTheme]) { - reportUnknownThemeError(theme); - return; - } - changeThemeQueryParam(nextTheme, uuiRouter); + toggleTheme: (nextTheme: ThemeId) => { + applyTheme(nextTheme); }, }; } @@ -62,7 +69,7 @@ export function AppTheme(props: { children: React.ReactNode }) { } function useThemeConfig() { - const [config, setConfig] = useState(null); + const [config, setConfig] = useState(null); useEffect(() => { let destroyed = false; loadListOfThemes() @@ -86,7 +93,7 @@ function useThemeConfig() { return config; } -async function loadListOfThemes(): Promise { +async function loadListOfThemes(): Promise { const customThemesArr = await loadCustomThemes(); const allThemes = [...builtInThemes, ...customThemesArr]; const themesById = allThemes.reduce>((acc, t) => { @@ -99,6 +106,6 @@ async function loadListOfThemes(): Promise { }; } -function reportUnknownThemeError(theme: TTheme) { +function reportUnknownThemeError(theme: ThemeId) { console.error(`[appTheme] Theme "${theme}" is unknown`); } diff --git a/app/src/helpers/appThemeUtils.ts b/app/src/helpers/appThemeUtils.ts index eafeb77f9f..ec64b4cbcc 100644 --- a/app/src/helpers/appThemeUtils.ts +++ b/app/src/helpers/appThemeUtils.ts @@ -1,5 +1,5 @@ import { getQuery, useQuery } from './getQuery'; -import { BuiltInTheme, ThemeBaseParams, TTheme } from '../data'; +import { BuiltInTheme, ThemeBaseParams, ThemeId } from '../data'; import { getUuiThemeRoot } from './appRootUtils'; import { settings } from '@epam/uui'; import { CustomThemeManifest } from '../data/customThemes'; @@ -18,21 +18,23 @@ export const overrideUuiSettings = ((_defaultSettings: string) => (newSettings: } })(JSON.stringify(settings)); -export type TThemeConfig = { - themes: TTheme[]; - themesById: Record; +export type ThemeConfig = CustomThemeManifest | ThemeBaseParams; + +export type ThemesConfig = { + themes: ThemeId[]; + themesById: Record; }; -export type TAppThemeContext = TThemeConfig & { theme: TTheme, toggleTheme: (newTheme: TTheme) => void }; +export type TAppThemeContext = ThemesConfig & { theme: ThemeId, toggleTheme: (newTheme: ThemeId) => void }; const QUERY_PARAM_THEME = 'theme'; const LOCAL_STORAGE_THEME_ITEM_ID = 'app-theme'; const DEFAULT_THEME = BuiltInTheme.loveship; -export const getCurrentTheme = (): TTheme => { +export const getCurrentTheme = (): ThemeId => { return getQuery(QUERY_PARAM_THEME) || getInitialThemeFallback(); }; -export function useCurrentTheme(config: TThemeConfig | undefined): TTheme | undefined { +export function useCurrentTheme(config: ThemesConfig | undefined): ThemeId | undefined { const { uuiRouter } = useUuiContext(); const param = useQuery(QUERY_PARAM_THEME); const theme = param ? param : getInitialThemeFallback(); @@ -40,7 +42,7 @@ export function useCurrentTheme(config: TThemeConfig | undefined): TTheme | unde useEffect(() => { if (config && !config.themesById[theme]) { console.error(`[useCurrentTheme] Theme "${theme}" is unknown. Redirecting to default theme "${DEFAULT_THEME}"`); - changeThemeQueryParam(DEFAULT_THEME, uuiRouter); + changeThemeQueryParam(config.themesById[DEFAULT_THEME], uuiRouter); } }, [config, theme, uuiRouter]); @@ -49,26 +51,26 @@ export function useCurrentTheme(config: TThemeConfig | undefined): TTheme | unde } } -export function changeThemeQueryParam(nextTheme: TTheme, uuiRouter: IRouterContext) { - const { pathname, query, ...restParams } = uuiRouter.getCurrentLink(); - uuiRouter.transfer({ pathname: pathname, query: { ...query, theme: nextTheme }, ...restParams }); -} +export const isCustomThemeConfig = (theme: ThemeConfig): theme is CustomThemeManifest => (theme as CustomThemeManifest).path !== undefined; -export function applyTheme(theme: TTheme, config: TThemeConfig) { - setThemeCssClass(theme); - saveThemeIdToLocalStorage(theme); - overrideUuiSettings((config.themesById[theme] as CustomThemeManifest).settings); +export function changeThemeQueryParam(nextTheme: ThemeConfig, uuiRouter: IRouterContext) { + const { pathname, query, ...restParams } = uuiRouter.getCurrentLink(); + const newQuery = { ...query, theme: nextTheme.id }; + if (isCustomThemeConfig(nextTheme)) { + newQuery.themePath = nextTheme.path; + } + uuiRouter.transfer({ pathname: pathname, query: newQuery, ...restParams }); } function getInitialThemeFallback() { return localStorage.getItem(LOCAL_STORAGE_THEME_ITEM_ID) || DEFAULT_THEME; } -function saveThemeIdToLocalStorage(theme: TTheme) { +export function saveThemeIdToLocalStorage(theme: ThemeId) { localStorage.setItem(LOCAL_STORAGE_THEME_ITEM_ID, theme); } -function setThemeCssClass(theme: TTheme) { +export function setThemeCssClass(theme: ThemeId) { const themeRoot = getUuiThemeRoot(); const currentTheme = themeRoot.classList.value.match(/uui-theme-(\S+)\s*/)[0].trim(); themeRoot.classList.replace(currentTheme, `uui-theme-${theme}`); diff --git a/app/src/preview/hooks/usePreviewParams.ts b/app/src/preview/hooks/usePreviewParams.ts index 353c0edca6..228c9da509 100644 --- a/app/src/preview/hooks/usePreviewParams.ts +++ b/app/src/preview/hooks/usePreviewParams.ts @@ -1,7 +1,7 @@ import { useEffect, useMemo } from 'react'; import { TPreviewContentParams } from '../types'; import { useQuery } from '../../helpers'; -import { BuiltInTheme, TTheme } from '../../data'; +import { BuiltInTheme, ThemeId } from '../../data'; import { parsePreviewIdFromString } from '../utils/previewLinkUtils'; import { useAppThemeContext } from '../../helpers/appTheme'; @@ -10,7 +10,7 @@ export function usePreviewParams(): TPreviewContentParams { const componentId: string = useQuery('componentId') || undefined; let previewId: string = useQuery('previewId') || undefined; previewId = previewId !== undefined ? String(previewId) : undefined; - const themeFromQuery = useQuery('theme') as TTheme || BuiltInTheme.promo; + const themeFromQuery = useQuery('theme') as ThemeId || BuiltInTheme.promo; const { theme, toggleTheme } = useAppThemeContext(); diff --git a/app/src/preview/types.ts b/app/src/preview/types.ts index 5422a021d3..cb54121712 100644 --- a/app/src/preview/types.ts +++ b/app/src/preview/types.ts @@ -1,8 +1,8 @@ import { TComponentPreview } from '@epam/uui-docs'; -import { TTheme } from '../data'; +import { ThemeId } from '../data'; export type TPreviewContentParams = { - theme: TTheme; + theme: ThemeId; isSkin: boolean; componentId: string | undefined; previewId: string | undefined | TComponentPreview; diff --git a/app/src/preview/utils/previewLinkUtils.ts b/app/src/preview/utils/previewLinkUtils.ts index 5b79fd522c..01937e7041 100644 --- a/app/src/preview/utils/previewLinkUtils.ts +++ b/app/src/preview/utils/previewLinkUtils.ts @@ -1,14 +1,14 @@ import { DocBuilder, PropDocPropsUnknown, TDocContext, TComponentPreview } from '@epam/uui-docs'; import { TPropInputDataAll } from '../../common/docs/properyEditor/propDocUtils'; import { TPreviewRef } from '../types'; -import { TTheme } from '../../data'; +import { ThemeId } from '../../data'; const INLINE_PREVIEW_PREFIX = 'json:'; type TBuildPreviewLinkParams = { context: TDocContext, inputData: TPropInputDataAll, - theme: TTheme, + theme: ThemeId, isSkin: boolean, componentId: string, docs: DocBuilder diff --git a/app/src/sandbox/tokens/palette/components/paletteTable/tableColumns.tsx b/app/src/sandbox/tokens/palette/components/paletteTable/tableColumns.tsx index 5d7c33bc6a..6e0144720e 100644 --- a/app/src/sandbox/tokens/palette/components/paletteTable/tableColumns.tsx +++ b/app/src/sandbox/tokens/palette/components/paletteTable/tableColumns.tsx @@ -18,7 +18,7 @@ import { TokenInfo } from '../tokenInfo/tokenInfo'; // import css from './paletteTable.module.scss'; import { getFigmaTheme } from '../../utils/themeVarUtils'; -import { TTheme } from '../../../../../data'; +import { ThemeId } from '../../../../../data'; const WIDTH = { [COL_NAMES.path]: 250, // E.g: core/surfaces/surface-main @@ -66,7 +66,7 @@ export const getSortBy = () => { }; export function getColumns( - params: { uuiTheme: TTheme, valueType: TThemeTokenValueType, filter: TLoadThemeTokensParams['filter'] }, + params: { uuiTheme: ThemeId, valueType: TThemeTokenValueType, filter: TLoadThemeTokensParams['filter'] }, ): DataColumnProps[] { const { uuiTheme, filter, valueType } = params; const figmaTheme = getFigmaTheme(uuiTheme); diff --git a/app/src/sandbox/tokens/palette/components/paletteTable/tokensSummary.tsx b/app/src/sandbox/tokens/palette/components/paletteTable/tokensSummary.tsx index dfd5a7128e..e8e5cf416d 100644 --- a/app/src/sandbox/tokens/palette/components/paletteTable/tokensSummary.tsx +++ b/app/src/sandbox/tokens/palette/components/paletteTable/tokensSummary.tsx @@ -3,10 +3,10 @@ import { useArrayDataSource } from '@epam/uui-core'; import { FlexRow, LabeledInput, PickerInput } from '@epam/uui'; import { TThemeTokenValueType } from '../../types/types'; import { getFigmaTheme } from '../../utils/themeVarUtils'; -import { TTheme } from '../../../../../data'; +import { ThemeId } from '../../../../../data'; type TokensSummaryProps = { - uuiTheme: TTheme, + uuiTheme: ThemeId, expectedValueType: TThemeTokenValueType, onChangeExpectedValueType: (v: TThemeTokenValueType) => void }; diff --git a/app/src/sandbox/tokens/palette/hooks/useThemeTokens/loadThemeTokens.ts b/app/src/sandbox/tokens/palette/hooks/useThemeTokens/loadThemeTokens.ts index 4557c8f306..dea14ea6e3 100644 --- a/app/src/sandbox/tokens/palette/hooks/useThemeTokens/loadThemeTokens.ts +++ b/app/src/sandbox/tokens/palette/hooks/useThemeTokens/loadThemeTokens.ts @@ -2,10 +2,10 @@ import { IThemeVar } from '../../types/sharedTypes'; import { svc } from '../../../../../services'; import { loadedTokensConverter } from './loadedTokensConverter'; import { IThemeVarUI, TLoadThemeTokensParams } from '../../types/types'; -import { TTheme } from '../../../../../data'; +import { ThemeId } from '../../../../../data'; const cache: { content: IThemeVar[] | undefined } = { content: undefined }; -export async function loadThemeTokens(params: TLoadThemeTokensParams & { uuiTheme: TTheme }): Promise { +export async function loadThemeTokens(params: TLoadThemeTokensParams & { uuiTheme: ThemeId }): Promise { if (!svc.api) { throw new Error('svc.api not available'); } diff --git a/app/src/sandbox/tokens/palette/hooks/useThemeTokens/loadedTokensConverter.ts b/app/src/sandbox/tokens/palette/hooks/useThemeTokens/loadedTokensConverter.ts index 9720744e90..f9f871c722 100644 --- a/app/src/sandbox/tokens/palette/hooks/useThemeTokens/loadedTokensConverter.ts +++ b/app/src/sandbox/tokens/palette/hooks/useThemeTokens/loadedTokensConverter.ts @@ -2,10 +2,10 @@ import { IThemeVar } from '../../types/sharedTypes'; import { IThemeVarUI, TLoadThemeTokensParams, TThemeTokenValueType } from '../../types/types'; import { getFigmaTheme, validateActualTokenValue } from '../../utils/themeVarUtils'; import { getBrowserTokens } from './browserTokens'; -import { TTheme } from '../../../../../data'; +import { ThemeId } from '../../../../../data'; export function loadedTokensConverter( - params: TLoadThemeTokensParams & { rawTokens: IThemeVar[], uuiTheme: TTheme }, + params: TLoadThemeTokensParams & { rawTokens: IThemeVar[], uuiTheme: ThemeId }, ) { const { rawTokens, uuiTheme, filter, valueType } = params; const browserTokens = getBrowserTokens(); diff --git a/app/src/sandbox/tokens/palette/types/types.ts b/app/src/sandbox/tokens/palette/types/types.ts index 066bbb4a46..f48f84ffe0 100644 --- a/app/src/sandbox/tokens/palette/types/types.ts +++ b/app/src/sandbox/tokens/palette/types/types.ts @@ -1,5 +1,5 @@ import { IThemeVar, TValueByThemeValue } from './sharedTypes'; -import { TTheme } from '../../../../data'; +import { ThemeId } from '../../../../data'; export enum TThemeVarUiErr { VAR_ABSENT= 'VAR_ABSENT', @@ -32,7 +32,7 @@ export interface TLoadThemeTokensParams { export type TLoadThemeTokensResult = { tokens: IThemeVarUI[], loading: boolean, - uuiTheme: TTheme, + uuiTheme: ThemeId, }; export interface TLoadThemeTokensFilter { path?: string, diff --git a/app/src/sandbox/tokens/palette/utils/themeVarUtils.ts b/app/src/sandbox/tokens/palette/utils/themeVarUtils.ts index 04a3f0fc93..3a003eb28f 100644 --- a/app/src/sandbox/tokens/palette/utils/themeVarUtils.ts +++ b/app/src/sandbox/tokens/palette/utils/themeVarUtils.ts @@ -1,5 +1,5 @@ import { IThemeVarUI, IThemeVarUIError, TThemeVarUiErr } from '../types/types'; -import { BuiltInTheme, TTheme } from '../../../../data'; +import { BuiltInTheme, ThemeId } from '../../../../data'; import { TFigmaThemeName, TVarType } from '../types/sharedTypes'; import { normalizeColor } from './colorUtils'; @@ -7,7 +7,7 @@ import { normalizeColor } from './colorUtils'; * The Figma theme names (i.e. the values) are hardcoded here. * Make sure they are updated when modes in "public/docs/figmaTokensGen/Theme.json" are changed. */ -const THEME_MAP: Record = { +const THEME_MAP: Record = { [BuiltInTheme.electric]: 'Electric', [BuiltInTheme.promo]: 'Promo', [BuiltInTheme.loveship]: 'Loveship-Light', @@ -15,7 +15,7 @@ const THEME_MAP: Record = { [BuiltInTheme.vanilla_thunder]: undefined, }; -export function getFigmaTheme(theme: TTheme) { +export function getFigmaTheme(theme: ThemeId) { return THEME_MAP[theme]; } diff --git a/changelog.md b/changelog.md index 37b6245d78..9d30445475 100644 --- a/changelog.md +++ b/changelog.md @@ -1,3 +1,16 @@ +# 5.x.x - xx.xx.2024 + +**What's New** + +**What's Fixed** +* [PickerInput]: fixed issue with clearing disabled (non-checkable) rows using backspace. +* [DataTableHeaderCell]: Fixed text selection issue that occurred when clicking on resize, without preventing the event from bubbling. +* [useLazyTree]: Fixed an issue where API calls were skipped during very fast scrolling. +* [RTE]: add `maxLength` prop +* [RTE]: fixed serialization of empty lines in HTML, now
html tag is used +* [Text]: Added missing skin colors to 'Loveship' 'light' (night900) and dark (night50, night300, night400, night500, night600, night700, night800, night900) themes. + + # 5.9.1 - 28.08.2024 **What's New** @@ -45,23 +58,23 @@ * Introduced a low-level `TimelineCanvas` component designed to render elements on the Timeline. * Enhanced customization options for `TimelineGrid` and `TimelineScale`. * Exposed default implementations for timeline grid/scale drawing phases via the `timelineGrid` and `timelineScale` libraries. + * [Breaking Change]: Removed `BaseTimelineCanvasComponent`. Use `TimelineCanvas` instead. Now, TimelineCanvas should not be extended, instead, `draw` function should be passed to the props. + * [Breaking Change]: Removed `TimelineScaleProps.shiftPercent`. * Added an example of Timeline usage with tables. [See demo here](https://uui.epam.com/demo?id=editableTable). - * Deprecated `BaseTimelineCanvasComponent`. Use `TimelineCanvas` instead. Now, TimelineCanvas should not be extended, instead, `draw` function should be passed to the props. * Added base component for universal drawing Timeline elements: `TimelineCanvas`. * Added the `useResizeObserver` hook, which provides the possibility to observe multiple elements' resizing. * Added the `useTimelineTransform` hook, which provides the possibility to receive the latest `TimelineTransform` instance from the `TimelineController`. - * Deprecated `TimelineScaleProps.shiftPercent`. * Made `TimelineGrid` customizable. Exposed default implementations of various parts of `TimelineGrid` drawing functionality, via the `timelineGrid` library. * Made `TimelineScale` customizable. Exposed default implementations of various parts of `TimelineScale` drawing functionality, via the `timelineScale` library. * Exposed default implementations of various parts of `Timeline` drawing functionality, via the `timelinePrimitives` library. * Added the `TimelineController.setViewportRange` function, which allows setting the `Viewport` by passing the right and left periods of the scale. * Added `computeSubtotals` and `createFromItems` to `Tree`. -* [DateTable]: +* [DateTable]: * Added `renderHeaderCell` callback to the column configuration, it's allows to provide custom render for column header. * Added the `DataTableCellContainer` component - base wrapper for header and column cells * [PickerInput]: hide picker footer while searching * [PickerInput]: made tags in multi select smaller -* [Typography]: only for electric theme: +* [Typography]: only for electric theme: * H1 weight changed from 600 to 400 * H2 weight changed from 700 to 600 diff --git a/epam-assets/theme/theme_loveship.scss b/epam-assets/theme/theme_loveship.scss index 101cb0bbf6..e55bc8a885 100644 --- a/epam-assets/theme/theme_loveship.scss +++ b/epam-assets/theme/theme_loveship.scss @@ -403,6 +403,10 @@ --uui-text: var(--night800); } + .uui-text.uui-color-night900 { + --uui-text: var(--night900); + } + .uui-text.uui-color-sky { --uui-text: var(--sky-70); } diff --git a/epam-assets/theme/theme_loveship_dark.scss b/epam-assets/theme/theme_loveship_dark.scss index 7ea1d1f0d1..887e18bdaa 100644 --- a/epam-assets/theme/theme_loveship_dark.scss +++ b/epam-assets/theme/theme_loveship_dark.scss @@ -758,6 +758,38 @@ --uui-text: var(--fire-70); } + .uui-text.uui-color-night50 { + --uui-text: var(--night50); + } + + .uui-text.uui-color-night300 { + --uui-text: var(--night300); + } + + .uui-text.uui-color-night400 { + --uui-text: var(--night400); + } + + .uui-text.uui-color-night500 { + --uui-text: var(--night500); + } + + .uui-text.uui-color-night600 { + --uui-text: var(--night600); + } + + .uui-text.uui-color-night700 { + --uui-text: var(--night700); + } + + .uui-text.uui-color-night800 { + --uui-text: var(--night800); + } + + .uui-text.uui-color-night900 { + --uui-text: var(--night900); + } + /* Typography */ .uui-typography { diff --git a/public/docs/content/examples-contexts-UseUuiServicesRR6.json b/public/docs/content/examples-contexts-UseUuiServicesRR6.json index 2c6945d689..07778f0776 100644 --- a/public/docs/content/examples-contexts-UseUuiServicesRR6.json +++ b/public/docs/content/examples-contexts-UseUuiServicesRR6.json @@ -6,13 +6,5 @@ "text": "Example how to assemble UUI context with react-router version 6." } ] - }, - { - "type": "note-error", - "children": [ - { - "text": "We strongly discourage the use of react-router 6, as it introduces too many breaking changes, and certain important features (like block and listen) are available only via unstable internal API." - } - ] } ] \ No newline at end of file diff --git a/public/docs/content/examples-dataSources-CustomHierarchicalList.json b/public/docs/content/examples-dataSources-CustomHierarchicalList.json new file mode 100644 index 0000000000..e35e67ed81 --- /dev/null +++ b/public/docs/content/examples-dataSources-CustomHierarchicalList.json @@ -0,0 +1,38 @@ +[ + { + "children": [ + { + "text": "You can build a Tree-like component based on the " + }, + { + "text": "DataSources", + "uui-richTextEditor-code": true + }, + { + "text": " and " + }, + { + "text": "VirtualList", + "uui-richTextEditor-code": true + }, + { + "text": ". To achieve this goal, define the view and retrieve its rows by calling " + }, + { + "text": "getVisibleRows", + "uui-richTextEditor-code": true + }, + { + "text": ". After that, render the rows using custom row components and pass them to the " + }, + { + "text": "VirtualList", + "uui-richTextEditor-code": true + }, + { + "text": "." + } + ], + "type": "paragraph" + } +] \ No newline at end of file diff --git a/public/docs/content/tree-descriptions.json b/public/docs/content/tree-descriptions.json new file mode 100644 index 0000000000..d55a76273b --- /dev/null +++ b/public/docs/content/tree-descriptions.json @@ -0,0 +1,10 @@ +[ + { + "type": "paragraph", + "children": [ + { + "text": "Tree-like components, such as Sidebars and Hierarchical Lists, can be built using a combination of DataSource and VirtualList. An example of a custom tree-like component is demonstrated below." + } + ] + } +] \ No newline at end of file diff --git a/uui-components/src/pickers/KeyboardUtils.tsx b/uui-components/src/pickers/KeyboardUtils.tsx index 822e1298ad..85c5e8a894 100644 --- a/uui-components/src/pickers/KeyboardUtils.tsx +++ b/uui-components/src/pickers/KeyboardUtils.tsx @@ -19,7 +19,9 @@ export const handleDataSourceKeyboard = (params: DataSourceKeyboardParams, e: Re if (params.searchPosition === 'input' && !value.search && value.checked && value.checked.length > 0) { const lastSelectionId = value.checked[value.checked.length - 1]; const lastSelection = params.listView.getById(lastSelectionId, null); - lastSelection.onCheck(lastSelection); + if (lastSelection.isCheckable) { + lastSelection.onCheck(lastSelection); + } } break; } diff --git a/uui-components/src/pickers/hooks/usePicker.ts b/uui-components/src/pickers/hooks/usePicker.ts index 6ea2b70caf..e6eecf07f3 100644 --- a/uui-components/src/pickers/hooks/usePicker.ts +++ b/uui-components/src/pickers/hooks/usePicker.ts @@ -61,6 +61,7 @@ export function usePicker setShowSelected(false); } + const newValue = dataSourceStateToValue(props, dataSourceState, dataSource); if ((!prevDataSourceState && (dataSourceState.checked?.length || dataSourceState.selectedId != null)) || (prevDataSourceState && ( !isEqual(prevDataSourceState.checked, dataSourceState.checked) @@ -68,12 +69,28 @@ export function usePicker && dataSourceState.selectedId !== prevDataSourceState.selectedId) )) ) { - const newValue = dataSourceStateToValue(props, dataSourceState, dataSource); if (!isEqual(value, newValue)) { handleSelectionValueChange(newValue); } } - }, [dataSourceState]); + + const { checked, selectedId } = getDataSourceState(); + + if (prevDataSourceState && ( + props.selectionMode === 'multi' + ? (isEqual(prevDataSourceState.checked, dataSourceState.checked) + && (checked?.length || dataSourceState.checked?.length) && !isEqual(dataSourceState.checked, checked)) + : ((dataSourceState.selectedId === prevDataSourceState.selectedId) + && (!isEqual(dataSourceState.selectedId, selectedId))) + )) { + handleDataSourceValueChange((dsState) => ({ + ...dsState, + ...(props.selectionMode === 'multi' + ? { checked } + : { selectedId }), + })); + } + }, [dataSourceState, value]); const getName = (i: (TItem & { name?: string }) | void) => { const unknownStr = 'Unknown'; diff --git a/uui-components/src/table/DataTableHeaderCell.tsx b/uui-components/src/table/DataTableHeaderCell.tsx index 8ec46be9e2..6d3e1386a7 100644 --- a/uui-components/src/table/DataTableHeaderCell.tsx +++ b/uui-components/src/table/DataTableHeaderCell.tsx @@ -56,9 +56,6 @@ export class DataTableHeaderCell extends React.Component { diff --git a/uui-core/src/data/processing/views/tree/hooks/strategies/lazyTree/useLazyFetchingAdvisor.ts b/uui-core/src/data/processing/views/tree/hooks/strategies/lazyTree/useLazyFetchingAdvisor.ts index 577460d321..b261116f37 100644 --- a/uui-core/src/data/processing/views/tree/hooks/strategies/lazyTree/useLazyFetchingAdvisor.ts +++ b/uui-core/src/data/processing/views/tree/hooks/strategies/lazyTree/useLazyFetchingAdvisor.ts @@ -2,15 +2,28 @@ import isEqual from 'react-fast-compare'; import { usePrevious } from '../../../../../../../hooks/usePrevious'; import { DataSourceState } from '../../../../../../../types'; import { isQueryChanged } from './helpers'; -import { useCallback, useMemo } from 'react'; +import { useCallback, useEffect, useMemo } from 'react'; import { useDepsChanged } from '../../common/useDepsChanged'; +export interface LazyFetchingAdvice { + shouldLoad: boolean; + shouldRefetch: boolean; + shouldFetch: boolean; + shouldReload: boolean; + updatedAt: number; +} + export interface UseLazyFetchingAdvisorProps { dataSourceState: DataSourceState; filter?: TFilter; forceReload?: boolean; backgroundReload?: boolean; showSelectedOnly?: boolean; + /** + * Fetching function, which should be called if fetch is required. + * @param lazyLoadingAdvice - fetching advice. + */ + onFetch?: (lazyLoadingAdvice: LazyFetchingAdvice) => void; } export function useLazyFetchingAdvisor( @@ -20,6 +33,7 @@ export function useLazyFetchingAdvisor( forceReload, backgroundReload, showSelectedOnly, + onFetch, }: UseLazyFetchingAdvisorProps, deps: any[] = [], ) { @@ -56,10 +70,33 @@ export function useLazyFetchingAdvisor( const shouldLoad = isFoldingChanged || moreRowsNeeded || shouldReload; const shouldFetch = shouldRefetch || isFoldingChanged || moreRowsNeeded; + const updatedAt = useMemo(() => Date.now(), [ + shouldLoad, + shouldReload, + shouldFetch, + shouldRefetch, + filter, + showSelectedOnly, + dataSourceState.folded, + dataSourceState.topIndex, + dataSourceState.visibleCount, + ]); + + useEffect(() => { + onFetch?.({ shouldFetch, shouldLoad, shouldReload, shouldRefetch, updatedAt }); + }, [shouldFetch, shouldLoad, shouldRefetch, shouldReload, updatedAt]); + return useMemo(() => ({ shouldLoad, shouldRefetch, shouldFetch, shouldReload, - }), [shouldLoad, shouldRefetch, shouldFetch]); + updatedAt, + }), [ + shouldLoad, + shouldRefetch, + shouldFetch, + shouldReload, + updatedAt, + ]); } diff --git a/uui-core/src/data/processing/views/tree/hooks/strategies/lazyTree/useLazyTree.ts b/uui-core/src/data/processing/views/tree/hooks/strategies/lazyTree/useLazyTree.ts index 7f48f38631..e80884a8fe 100644 --- a/uui-core/src/data/processing/views/tree/hooks/strategies/lazyTree/useLazyTree.ts +++ b/uui-core/src/data/processing/views/tree/hooks/strategies/lazyTree/useLazyTree.ts @@ -88,7 +88,7 @@ export function useLazyTree( return newTree.full; }, [loadMissingOnCheck, setTreeWithData, treeWithData]); - const { shouldRefetch, shouldLoad, shouldFetch } = useLazyFetchingAdvisor({ + const { shouldRefetch, shouldLoad, shouldFetch, shouldReload, updatedAt } = useLazyFetchingAdvisor({ dataSourceState, filter, forceReload: isForceReload, @@ -118,7 +118,7 @@ export function useLazyTree( } }); } - }, [showSelectedOnly, dataSourceState.checked, dataSourceState.selectedId]); + }, [showSelectedOnly, dataSourceState.checked, dataSourceState.selectedId, shouldRefetch, updatedAt]); useEffect(() => { if (showSelectedOnly) { @@ -161,6 +161,8 @@ export function useLazyTree( shouldFetch, shouldLoad, shouldRefetch, + shouldReload, + updatedAt, ]); const treeWithSelectedOnly = useSelectedOnlyTree({ diff --git a/uui-e2e-tests/tests/previewTests/__screenshots__/linux/chromium/Text-ColorVariants-LoveshipDark-Skin.png b/uui-e2e-tests/tests/previewTests/__screenshots__/linux/chromium/Text-ColorVariants-LoveshipDark-Skin.png index 253a315cc5..96a988e276 100644 Binary files a/uui-e2e-tests/tests/previewTests/__screenshots__/linux/chromium/Text-ColorVariants-LoveshipDark-Skin.png and b/uui-e2e-tests/tests/previewTests/__screenshots__/linux/chromium/Text-ColorVariants-LoveshipDark-Skin.png differ diff --git a/uui-e2e-tests/tests/previewTests/__screenshots__/linux/webkit/Text-ColorVariants-LoveshipDark-Skin.png b/uui-e2e-tests/tests/previewTests/__screenshots__/linux/webkit/Text-ColorVariants-LoveshipDark-Skin.png index bddfd62225..248468be13 100644 Binary files a/uui-e2e-tests/tests/previewTests/__screenshots__/linux/webkit/Text-ColorVariants-LoveshipDark-Skin.png and b/uui-e2e-tests/tests/previewTests/__screenshots__/linux/webkit/Text-ColorVariants-LoveshipDark-Skin.png differ diff --git a/uui-editor/src/SlateEditor.tsx b/uui-editor/src/SlateEditor.tsx index a7bbff57f6..96e9e983c3 100644 --- a/uui-editor/src/SlateEditor.tsx +++ b/uui-editor/src/SlateEditor.tsx @@ -18,11 +18,12 @@ import css from './SlateEditor.module.scss'; import { useFocusEvents } from './plugins/eventEditorPlugin'; import { isEditorValueEmpty } from './helpers'; import { getMigratedPlateValue, isPlateValue } from './migrations'; +import { PlateProps } from '@udecode/plate-core'; export interface PlateEditorProps extends IEditable, IHasCX, - IHasRawProps> { + IHasRawProps>, Pick { plugins: PlatePlugin[]; isReadonly?: boolean; autoFocus?: boolean; @@ -148,6 +149,7 @@ export const SlateEditor = memo(forwardRef((pr plugins={ plugins } onChange={ onChange } editorRef={ editorRef } + maxLength={ props.maxLength } >
{ const editor = createTempEditor(htmlSerializationsWorkingPlugins); return (v: EditorValue) => { const value = initializeEditor(editor, v); - return serializeHtml(editor, { nodes: value }); + return serializeHtml(editor, { nodes: value, convertNewLinesToHtmlBr: true }); }; } else { const editor = createTempEditor(mdSerializationsWorkingPlugins); diff --git a/uui/components/datePickers/RangeDatePicker.tsx b/uui/components/datePickers/RangeDatePicker.tsx index 2c5711c469..2c5d890556 100644 --- a/uui/components/datePickers/RangeDatePicker.tsx +++ b/uui/components/datePickers/RangeDatePicker.tsx @@ -91,6 +91,7 @@ function RangeDatePickerComponent(props: RangeDatePickerProps, ref: React.Forwar renderTarget={ (renderProps) => { return props.renderTarget?.(renderProps) || ( ) => { if (e.key === 'Enter') { - onClick(); + onClick(); e.preventDefault(); } }; diff --git a/uui/components/datePickers/__tests__/RangeDatePicker.test.tsx b/uui/components/datePickers/__tests__/RangeDatePicker.test.tsx index deea0a4c7a..a5c6465a80 100644 --- a/uui/components/datePickers/__tests__/RangeDatePicker.test.tsx +++ b/uui/components/datePickers/__tests__/RangeDatePicker.test.tsx @@ -71,6 +71,7 @@ describe('RangeDataPicker', () => { it('should be rendered if many params defined', async () => { const tree = await renderSnapshotWithContextAsync( (param }; } +async function setupPickerInputForTestWithFirstValueChangeRewriting( + params: Partial & { valueForFirstUpdate: TItem | TId | TItem[] | TId[] }>, +) { + const { result, mocks, setProps } = await setupComponentForTest>( + (context): PickerInputComponentProps => { + if (params.selectionMode === 'single') { + let updatesCounter = 0; + return Object.assign({ + onValueChange: jest.fn().mockImplementation((newValue) => { + if (updatesCounter === 0) { + updatesCounter++; + return context.current?.setProperty('value', params.valueForFirstUpdate); + } + + if (typeof newValue === 'function') { + const v = newValue(params.value); + context.current?.setProperty('value', v); + } + context.current?.setProperty('value', newValue); + }), + dataSource: mockDataSourceAsync, + disableClear: false, + searchPosition: 'input', + getName: (item: TestItemType) => item.level, + value: params.value as TId, + searchDebounceDelay: 0, + }, params) as PickerInputComponentProps; + } + + let updatesCounter = 0; + return Object.assign({ + onValueChange: jest.fn().mockImplementation((newValue) => { + if (updatesCounter === 0) { + updatesCounter++; + return context.current?.setProperty('value', params.valueForFirstUpdate); + } + + if (typeof newValue === 'function') { + const v = newValue(params.value); + context.current?.setProperty('value', v); + return; + } + context.current?.setProperty('value', newValue); + }), + dataSource: mockDataSourceAsync, + disableClear: false, + searchPosition: 'input', + getName: (item: TestItemType) => item.level, + value: params.value as number[], + selectionMode: 'multi', + searchDebounceDelay: 0, + }, params) as PickerInputComponentProps; + }, + (props) => ( + <> + + + + ), + ); + const input = screen.queryByRole('textbox') as HTMLElement; + + return { + setProps, + result, + mocks, + dom: { input, container: result.container, target: result.container.firstElementChild as HTMLElement }, + }; +} + describe('PickerInput', () => { beforeEach(() => { jest.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(() => { @@ -172,6 +242,39 @@ describe('PickerInput', () => { expect(screen.queryByText('C2')).not.toBeInTheDocument(); }); + it('[valueType id] should listen to value change', async () => { + const { dom, mocks } = await setupPickerInputForTestWithFirstValueChangeRewriting({ + selectionMode: 'single', + }); + expect(PickerInputTestObject.getPlaceholderText(dom.input)).toEqual('Please select'); + fireEvent.click(dom.input); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + const optionC2 = await screen.findByText('C2'); + fireEvent.click(optionC2); + await waitFor(() => { + expect(mocks.onValueChange).toHaveBeenLastCalledWith(12); + }); + + fireEvent.click(window.document.body); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + + await waitFor(() => { + expect(screen.queryByText('C2')).not.toBeInTheDocument(); + }); + + fireEvent.click(dom.input); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + const option2C2 = await screen.findByText('C2'); + fireEvent.click(option2C2); + + await waitFor(() => { + expect(mocks.onValueChange).toHaveBeenLastCalledWith(12); + }); + await waitFor(() => { + expect(screen.getByPlaceholderText('C2')).toBeInTheDocument(); + }); + }); + it('should close body on click outside', async () => { const { dom } = await setupPickerInputForTest({ value: undefined, @@ -251,6 +354,41 @@ describe('PickerInput', () => { }); }); + it('[valueType entity] should listen to value change', async () => { + const { dom, mocks } = await setupPickerInputForTestWithFirstValueChangeRewriting({ + value: undefined, + selectionMode: 'single', + valueType: 'entity', + }); + expect(PickerInputTestObject.getPlaceholderText(dom.input)).toEqual('Please select'); + fireEvent.click(dom.input); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + const optionC2 = await screen.findByText('C2'); + fireEvent.click(optionC2); + await waitFor(() => { + expect(mocks.onValueChange).toHaveBeenLastCalledWith({ id: 12, level: 'C2', name: 'Proficiency' }); + }); + + fireEvent.click(window.document.body); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + + await waitFor(() => { + expect(screen.queryByText('C2')).not.toBeInTheDocument(); + }); + + fireEvent.click(dom.input); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + const option2C2 = await screen.findByText('C2'); + fireEvent.click(option2C2); + + await waitFor(() => { + expect(mocks.onValueChange).toHaveBeenLastCalledWith({ id: 12, level: 'C2', name: 'Proficiency' }); + }); + await waitFor(() => { + expect(screen.getByPlaceholderText('C2')).toBeInTheDocument(); + }); + }); + it('should render names of items by getName', async () => { const { mocks, dom } = await setupPickerInputForTest({ value: 3, @@ -534,6 +672,85 @@ describe('PickerInput', () => { expect(await PickerInputTestObject.getSelectedTagsText(dom.target)).toEqual(['A1', 'A1+']); }); + it('[valueType id] should listen to value change', async () => { + const { dom, mocks } = await setupPickerInputForTestWithFirstValueChangeRewriting({ + valueForFirstUpdate: [4], + value: undefined, + selectionMode: 'multi', + valueType: 'id', + }); + + expect(PickerInputTestObject.getPlaceholderText(dom.input)).toEqual('Please select'); + fireEvent.click(dom.input); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + + await PickerInputTestObject.clickOptionCheckbox('A1'); + + await waitFor(() => { + expect(mocks.onValueChange).toHaveBeenLastCalledWith([2]); + }); + expect(await PickerInputTestObject.getSelectedTagsText(dom.target)).toEqual(['A2']); + + fireEvent.click(window.document.body); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + + fireEvent.click(dom.input); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + await PickerInputTestObject.clickOptionCheckbox('A1'); + + await waitFor(() => { + expect(mocks.onValueChange).toHaveBeenLastCalledWith([4, 2]); + }); + expect(await PickerInputTestObject.getSelectedTagsText(dom.target)).toEqual(['A2', 'A1']); + }); + + it('[valueType entity] should listen to value change', async () => { + const { dom, mocks } = await setupPickerInputForTestWithFirstValueChangeRewriting({ + valueForFirstUpdate: [{ id: 4, level: 'A2', name: 'Pre-Intermediate' }], + value: undefined, + selectionMode: 'multi', + valueType: 'entity', + }); + + expect(PickerInputTestObject.getPlaceholderText(dom.input)).toEqual('Please select'); + fireEvent.click(dom.input); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + + await PickerInputTestObject.clickOptionCheckbox('A1'); + + await waitFor(() => { + expect(mocks.onValueChange).toHaveBeenLastCalledWith([{ + id: 2, + level: 'A1', + name: 'Elementary', + }]); + }); + expect(await PickerInputTestObject.getSelectedTagsText(dom.target)).toEqual(['A2']); + + fireEvent.click(window.document.body); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + + fireEvent.click(dom.input); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + await act(async () => { + await PickerInputTestObject.clickOptionCheckbox('A1'); + }); + + await waitFor(() => { + expect(mocks.onValueChange).toHaveBeenLastCalledWith([{ + id: 4, + level: 'A2', + name: 'Pre-Intermediate', + }, + { + id: 2, + level: 'A1', + name: 'Elementary', + }]); + }); + expect(await PickerInputTestObject.getSelectedTagsText(dom.target)).toEqual(['A2', 'A1']); + }); + it('[valueType entity] should select & clear several options', async () => { const { dom, mocks } = await setupPickerInputForTest({ value: undefined, @@ -1398,11 +1615,38 @@ describe('PickerInput', () => { }); }); + it('should not remove disabled item from selection by backspace if in case of searchPosition="input"', async () => { + const { dom, mocks } = await setupPickerInputForTest({ + value: [2, 3, 4], + selectionMode: 'multi', + searchPosition: 'input', + getRowOptions: () => ({ checkbox: { isVisible: true, isDisabled: true } }), + }); + + fireEvent.click(dom.input); + + await PickerInputTestObject.waitForOptionsToBeReady(); + + fireEvent.keyDown(dom.input, { key: 'Backspace', code: 'Backspace', charCode: 8 }); + + await waitFor(() => { + expect(mocks.onValueChange).toHaveBeenCalledTimes(0); + }); + + await waitFor(() => { + expect(PickerInputTestObject.getSelectedTagsText(dom.target)).toEqual([ + 'A1', + 'A1+', + 'A2', + ]); + }); + }); + it.each<[undefined | null | []]>([[[]], [undefined], [null]]) ('should not call onValueChange on edit search with emptyValue = %s; and return emptyValue = %s on check -> uncheck', async (emptyValue) => { const { dom, mocks } = await setupPickerInputForTest({ emptyValue: emptyValue, - value: emptyValue, + value: emptyValue as (undefined | []), selectionMode: 'multi', searchPosition: 'body', }); diff --git a/uui/components/tables/DataTableHeaderCell.module.scss b/uui/components/tables/DataTableHeaderCell.module.scss index 47824b0682..8e1a4fa5d0 100644 --- a/uui/components/tables/DataTableHeaderCell.module.scss +++ b/uui/components/tables/DataTableHeaderCell.module.scss @@ -153,6 +153,7 @@ right: 0; height: 100%; cursor: col-resize; + user-select: none; &:hover { background-color: var(--uui-dt-cell-bg-resize);