From 6871f0c0b68aac482c860b32de45f98b810fdfe6 Mon Sep 17 00:00:00 2001 From: cixzhang Date: Sun, 2 Aug 2026 17:49:05 +0000 Subject: [PATCH 1/5] chore: remove deprecated APIs --- .changeset/remove-deprecated-apis.md | 8 + .../src/app/(raw)/pages/shell-lab/page.tsx | 30 +- .../pages/doc-preview/DocPreview.tsx | 7 +- .../pages/doc-preview/SectionRenderer.tsx | 5 +- .../content-blocks/ContentBlockRenderer.tsx | 2 +- .../app/(sandbox)/pages/doc-preview/page.tsx | 65 +- .../app/(sandbox)/pages/table-lab/page.tsx | 20 +- apps/storybook/stories/Dialog.stories.tsx | 4 +- .../stories/TableRowExpansion.stories.tsx | 64 +- packages/cli/api/template/list/list.mjs | 2 - .../cli/api/template/skeleton/skeleton.mjs | 1 - packages/cli/api/template/template.type.mjs | 1 - .../Table/TableRowExpansionTable.doc.mjs | 14 - .../Table/TableRowExpansionTable.tsx | 99 --- .../templates/pages/shell-top-nav/page.tsx | 2 +- .../clients/cli/commands/component.test.mjs | 66 -- .../clients/cli/commands/component/index.mjs | 2 +- .../discovery/component-discovery.mjs | 31 - .../discovery/component-discovery.test.mjs | 5 - packages/core/src/Calendar/Calendar.test.tsx | 10 +- packages/core/src/Calendar/index.ts | 7 +- packages/core/src/Calendar/utils.ts | 11 - .../CommandPaletteGroup.test.tsx | 8 +- .../core/src/DateInput/DateInput.test.tsx | 10 +- .../DateRangeInput/DateRangeInput.test.tsx | 9 +- .../src/DateTimeInput/DateTimeInput.test.tsx | 8 +- packages/core/src/Dialog/Dialog.doc.mjs | 4 +- packages/core/src/Dialog/Dialog.test.tsx | 48 +- packages/core/src/Dialog/Dialog.tsx | 43 +- packages/core/src/Grid/Grid.doc.mjs | 5 - packages/core/src/Grid/Grid.tsx | 24 - .../src/MultiSelector/MultiSelector.test.tsx | 10 +- .../core/src/NavMenu/NavHeadingMenu.test.tsx | 16 - packages/core/src/NavMenu/NavMenuItem.tsx | 19 - packages/core/src/NavMenu/index.ts | 4 - packages/core/src/Selector/Selector.test.tsx | 10 +- packages/core/src/Switch/Switch.doc.mjs | 4 +- packages/core/src/Switch/Switch.test.tsx | 25 - packages/core/src/Switch/Switch.tsx | 17 +- packages/core/src/Table/BaseTable.tsx | 7 +- packages/core/src/Table/Table.test.tsx | 49 -- packages/core/src/Table/index.ts | 5 - .../groupedRows/useTableGroupedRows.tsx | 2 +- .../src/Table/plugins/rowExpansion/index.ts | 7 - .../useTableRowExpansion.test.tsx | 263 ------- .../rowExpansion/useTableRowExpansion.tsx | 650 ------------------ packages/core/src/Table/types.ts | 9 - .../src/Table/useTableGroupedRows.doc.mjs | 2 +- .../src/Table/useTableRowExpansion.doc.mjs | 133 ---- packages/core/src/TopNav/TopNav.test.tsx | 4 +- .../core/src/TopNav/TopNavHeading.doc.mjs | 11 - packages/core/src/TopNav/TopNavHeading.tsx | 11 +- packages/core/src/TreeList/TreeList.test.tsx | 16 +- packages/core/src/index.ts | 21 - packages/core/src/theme/defineTheme.test.ts | 59 +- packages/core/src/theme/defineTheme.ts | 4 - packages/core/src/theme/generateThemeRules.ts | 15 - packages/core/src/theme/index.ts | 4 - packages/core/src/theme/tokens.stylex.ts | 19 - packages/lab/src/CodeBlock/index.ts | 10 - packages/lab/src/index.ts | 1 - 61 files changed, 218 insertions(+), 1804 deletions(-) create mode 100644 .changeset/remove-deprecated-apis.md delete mode 100644 packages/cli/assets/templates/blocks/components/Table/TableRowExpansionTable.doc.mjs delete mode 100644 packages/cli/assets/templates/blocks/components/Table/TableRowExpansionTable.tsx delete mode 100644 packages/core/src/NavMenu/NavMenuItem.tsx delete mode 100644 packages/core/src/Table/plugins/rowExpansion/index.ts delete mode 100644 packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.test.tsx delete mode 100644 packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx delete mode 100644 packages/core/src/Table/useTableRowExpansion.doc.mjs delete mode 100644 packages/lab/src/CodeBlock/index.ts diff --git a/.changeset/remove-deprecated-apis.md b/.changeset/remove-deprecated-apis.md new file mode 100644 index 000000000000..42eb90b26b79 --- /dev/null +++ b/.changeset/remove-deprecated-apis.md @@ -0,0 +1,8 @@ +--- +'@astryxdesign/core': minor +'@astryxdesign/cli': minor +--- + +[breaking] Remove long-deprecated compatibility APIs from core and CLI. Run `astryx upgrade` first to migrate the supported replacements for authoring imports, Dialog logical positions, Switch label spacing, and Table root props. + +@cixzhang diff --git a/apps/sandbox/src/app/(raw)/pages/shell-lab/page.tsx b/apps/sandbox/src/app/(raw)/pages/shell-lab/page.tsx index 0ff753c38c59..135797836694 100644 --- a/apps/sandbox/src/app/(raw)/pages/shell-lab/page.tsx +++ b/apps/sandbox/src/app/(raw)/pages/shell-lab/page.tsx @@ -34,7 +34,7 @@ import {Badge} from '@astryxdesign/core/Badge'; import {Button} from '@astryxdesign/core/Button'; import {NavIcon} from '@astryxdesign/core/NavIcon'; import {Banner} from '@astryxdesign/core/Banner'; -import {NavMenuItem} from '@astryxdesign/core/NavMenu'; +import {NavHeadingMenuItem} from '@astryxdesign/core/NavMenu'; // ============================================================================= // Configuration types @@ -579,9 +579,9 @@ function SampleSideNav({ const headingMenu = ( <> - {}} /> - {}} /> - {}} /> + {}} /> + {}} /> + {}} /> ); @@ -630,9 +630,7 @@ function SampleSideNav({ resizable={config.isResizable} header={heading} topContent={ - config.showTopContent ? ( - - ) : undefined + config.showTopContent ? : undefined } footer={ config.showFooter ? ( @@ -844,9 +842,9 @@ function SampleTopNav({ const topNavHeadingMenu = ( <> - {}} /> - {}} /> - {}} /> + {}} /> + {}} /> + {}} /> ); @@ -1043,16 +1041,8 @@ function ShellLabContent() { href="#" icon={DashboardIcon} /> - - + + ), diff --git a/apps/sandbox/src/app/(sandbox)/pages/doc-preview/DocPreview.tsx b/apps/sandbox/src/app/(sandbox)/pages/doc-preview/DocPreview.tsx index 727c2a26b7a8..2a53ede0177e 100644 --- a/apps/sandbox/src/app/(sandbox)/pages/doc-preview/DocPreview.tsx +++ b/apps/sandbox/src/app/(sandbox)/pages/doc-preview/DocPreview.tsx @@ -8,7 +8,7 @@ import {VStack} from '@astryxdesign/core/Layout'; import {Text} from '@astryxdesign/core/Text'; import {Divider} from '@astryxdesign/core/Divider'; import type {DefinedTheme} from '@astryxdesign/core/theme'; -import type {ReferenceDoc, ReferenceSection} from '@astryxdesign/core'; +import type {ReferenceDoc, ReferenceSection} from '@astryxdesign/cli/authoring'; import {SectionRenderer} from './SectionRenderer'; import { ColorTokenTable, @@ -137,10 +137,7 @@ export function DocPreview({ {doc.title} {version && ( - + v{version} )} diff --git a/apps/sandbox/src/app/(sandbox)/pages/doc-preview/SectionRenderer.tsx b/apps/sandbox/src/app/(sandbox)/pages/doc-preview/SectionRenderer.tsx index bc4091bff2e1..926e7740ec28 100644 --- a/apps/sandbox/src/app/(sandbox)/pages/doc-preview/SectionRenderer.tsx +++ b/apps/sandbox/src/app/(sandbox)/pages/doc-preview/SectionRenderer.tsx @@ -6,7 +6,10 @@ import {useMemo} from 'react'; import * as stylex from '@stylexjs/stylex'; import {VStack} from '@astryxdesign/core/Layout'; import {Heading} from '@astryxdesign/core/Text'; -import type {ReferenceSection, ReferenceContentBlock} from '@astryxdesign/core'; +import type { + ReferenceSection, + ReferenceContentBlock, +} from '@astryxdesign/cli/authoring'; import {ContentBlockRenderer} from './content-blocks'; const styles = stylex.create({ diff --git a/apps/sandbox/src/app/(sandbox)/pages/doc-preview/content-blocks/ContentBlockRenderer.tsx b/apps/sandbox/src/app/(sandbox)/pages/doc-preview/content-blocks/ContentBlockRenderer.tsx index 8b9fc603e928..443b2747733b 100644 --- a/apps/sandbox/src/app/(sandbox)/pages/doc-preview/content-blocks/ContentBlockRenderer.tsx +++ b/apps/sandbox/src/app/(sandbox)/pages/doc-preview/content-blocks/ContentBlockRenderer.tsx @@ -2,7 +2,7 @@ 'use client'; -import type {ReferenceContentBlock} from '@astryxdesign/core'; +import type {ReferenceContentBlock} from '@astryxdesign/cli/authoring'; import {ProseBlock} from './ProseBlock'; import {CodeBlock} from './CodeBlock'; import {TableBlock} from './TableBlock'; diff --git a/apps/sandbox/src/app/(sandbox)/pages/doc-preview/page.tsx b/apps/sandbox/src/app/(sandbox)/pages/doc-preview/page.tsx index acf34ef08447..ecc10f700ada 100644 --- a/apps/sandbox/src/app/(sandbox)/pages/doc-preview/page.tsx +++ b/apps/sandbox/src/app/(sandbox)/pages/doc-preview/page.tsx @@ -1,61 +1,61 @@ // Copyright (c) Meta Platforms, Inc. and affiliates. -"use client"; +'use client'; -import { useState, useEffect } from "react"; -import * as stylex from "@stylexjs/stylex"; -import { HStack } from "@astryxdesign/core/Layout"; -import { Text } from "@astryxdesign/core/Text"; +import {useState, useEffect} from 'react'; +import * as stylex from '@stylexjs/stylex'; +import {HStack} from '@astryxdesign/core/Layout'; +import {Text} from '@astryxdesign/core/Text'; import { SegmentedControl, SegmentedControlItem, -} from "@astryxdesign/core/SegmentedControl"; -import { Spinner } from "@astryxdesign/core/Spinner"; -import type { ReferenceDoc } from "@astryxdesign/core"; -import { DocPreview } from "./DocPreview"; -import { ThemeContext } from "@astryxdesign/core/theme"; -import { useContext } from "react"; +} from '@astryxdesign/core/SegmentedControl'; +import {Spinner} from '@astryxdesign/core/Spinner'; +import type {ReferenceDoc} from '@astryxdesign/cli/authoring'; +import {DocPreview} from './DocPreview'; +import {ThemeContext} from '@astryxdesign/core/theme'; +import {useContext} from 'react'; const styles = stylex.create({ page: { - minHeight: "100vh", + minHeight: '100vh', }, topBar: { - position: "sticky", + position: 'sticky', top: 0, zIndex: 10, - backgroundColor: "var(--color-background-body)", - borderBottom: "1px solid var(--color-border)", - padding: "12px 32px", + backgroundColor: 'var(--color-background-body)', + borderBottom: '1px solid var(--color-border)', + padding: '12px 32px', }, loading: { - display: "flex", - alignItems: "center", - justifyContent: "center", + display: 'flex', + alignItems: 'center', + justifyContent: 'center', padding: 64, }, }); /** Foundational doc topics — the section docs that have tokenCategory */ const TOPICS = [ - { value: "color", label: "Color" }, - { value: "spacing", label: "Spacing" }, - { value: "typography", label: "Typography" }, - { value: "elevation", label: "Elevation" }, - { value: "shape", label: "Shape" }, - { value: "motion", label: "Motion" }, + {value: 'color', label: 'Color'}, + {value: 'spacing', label: 'Spacing'}, + {value: 'typography', label: 'Typography'}, + {value: 'elevation', label: 'Elevation'}, + {value: 'shape', label: 'Shape'}, + {value: 'motion', label: 'Motion'}, ] as const; export default function DocPreviewPage() { - const [topic, setTopic] = useState("color"); + const [topic, setTopic] = useState('color'); const [docs, setDocs] = useState>({}); const [version, setVersion] = useState(''); const [loading, setLoading] = useState(true); // Load pre-generated doc data useEffect(() => { - import("../../../../generated/foundationDocs.json") - .then((mod) => { + import('../../../../generated/foundationDocs.json') + .then(mod => { const data = mod.default as Record; setVersion((data.__version as string) ?? ''); const {__version: _, ...topics} = data; @@ -79,9 +79,8 @@ export default function DocPreviewPage() { value={topic} onChange={setTopic} label="Doc topic" - size="sm" - > - {TOPICS.map((t) => ( + size="sm"> + {TOPICS.map(t => ( ) : doc ? ( - ctx?.theme ? : null + ctx?.theme ? ( + + ) : null ) : (
No doc found for "{topic}" diff --git a/apps/sandbox/src/app/(sandbox)/pages/table-lab/page.tsx b/apps/sandbox/src/app/(sandbox)/pages/table-lab/page.tsx index 141f0a593166..b73e34823192 100644 --- a/apps/sandbox/src/app/(sandbox)/pages/table-lab/page.tsx +++ b/apps/sandbox/src/app/(sandbox)/pages/table-lab/page.tsx @@ -45,8 +45,8 @@ import { paginateData, useTableColumnResize, useTableStickyColumns, - useTableRowExpansion, - useTableRowExpansionState, + useTableTreeData, + useTableTreeState, useTableGroupedRows, useTableRowIndex, useTableRowStatus, @@ -205,16 +205,16 @@ function useLabPlugins({ endKeys: ['joined'], }); - // --- row expansion (flat rows; no real tree in the synthetic data) --- + // --- tree rows (flat rows; no real tree in the synthetic data) --- const [expandedKeys, setExpandedKeys] = useState>(new Set()); - const {expansionConfig} = useTableRowExpansionState({ - baseData: dataAfterPage, - getChildren: () => [], - getRowKey: item => item.id, - expandedKeys, - setExpandedKeys, + const {visibleData, treeConfig} = useTableTreeState({ + data: dataAfterPage, + idKey: 'id', + expandedIds: expandedKeys, + onExpandedIdsChange: ids => setExpandedKeys(new Set(ids)), + isItemExpandable: () => false, }); - const rowExpansionPlugin = useTableRowExpansion(expansionConfig); + const treePlugin = useTableTreeData(treeConfig); if (enabled.rowExpansion) { summary.push(`${expandedKeys.size} expanded`); } diff --git a/apps/storybook/stories/Dialog.stories.tsx b/apps/storybook/stories/Dialog.stories.tsx index 65ef5ace8f7d..6e328b15dc65 100644 --- a/apps/storybook/stories/Dialog.stories.tsx +++ b/apps/storybook/stories/Dialog.stories.tsx @@ -468,7 +468,7 @@ function PositionedModalExample() { setIsOpen(open)} - position={{top: 100, right: 20}} + position={{top: 100, end: 20}} width={350}> - This modal is positioned at top: 100px, right: 20px instead of + This modal is positioned at top: 100px, end: 20px instead of being centered. diff --git a/apps/storybook/stories/TableRowExpansion.stories.tsx b/apps/storybook/stories/TableRowExpansion.stories.tsx index be1f12c54d10..29e79c71a8b3 100644 --- a/apps/storybook/stories/TableRowExpansion.stories.tsx +++ b/apps/storybook/stories/TableRowExpansion.stories.tsx @@ -4,8 +4,8 @@ import {useState} from 'react'; import type {Meta, StoryObj} from '@storybook/react'; import { Table, - useTableRowExpansion, - useTableRowExpansionState, + useTableTreeData, + useTableTreeState, pixel, proportional, } from '@astryxdesign/core/Table'; @@ -147,7 +147,7 @@ const columns: TableColumn[] = [ // ============================================================================= const meta: Meta = { - title: 'Core/TableRowExpansion', + title: 'Core/TableTree', tags: ['autodocs'], }; @@ -167,23 +167,23 @@ export const InheritedColumns: Story = { // The state hook flattens the tree, tracks depth, and derives the // expand/collapse + expand-all handlers — no boilerplate in the consumer. - const {data, expansionConfig} = useTableRowExpansionState({ - baseData: fileTree, - getChildren: item => item.children ?? [], - getRowKey: item => item.id, - expandedKeys, - setExpandedKeys, + const {data, treeConfig} = useTableTreeState({ + data: fileTree, + idKey: 'id', + childrenKey: 'children', + expandedIds: expandedKeys, + onExpandedIdsChange: ids => setExpandedKeys(new Set(ids)), }); - const expansion = useTableRowExpansion(expansionConfig); + const tree = useTableTreeData(treeConfig); return ( ); }, @@ -200,24 +200,24 @@ export const LeafNodesNotExpandable: Story = { ); // `getIsItemExpandable` restricts expandability (and expand-all) to folders. - const {data, expansionConfig} = useTableRowExpansionState({ - baseData: fileTree, - getChildren: item => item.children ?? [], - getRowKey: item => item.id, - getIsItemExpandable: item => item.type === 'folder', - expandedKeys, - setExpandedKeys, + const {data, treeConfig} = useTableTreeState({ + data: fileTree, + idKey: 'id', + childrenKey: 'children', + expandedIds: expandedKeys, + onExpandedIdsChange: ids => setExpandedKeys(new Set(ids)), + isItemExpandable: item => item.type === 'folder', }); - const expansion = useTableRowExpansion(expansionConfig); + const tree = useTableTreeData(treeConfig); return (
); }, @@ -231,27 +231,27 @@ export const ExpandOnRowClick: Story = { render: () => { const [expandedKeys, setExpandedKeys] = useState>(new Set()); - const {data, expansionConfig} = useTableRowExpansionState({ - baseData: fileTree, - getChildren: item => item.children ?? [], - getRowKey: item => item.id, - expandedKeys, - setExpandedKeys, + const {data, treeConfig} = useTableTreeState({ + data: fileTree, + idKey: 'id', + childrenKey: 'children', + expandedIds: expandedKeys, + onExpandedIdsChange: ids => setExpandedKeys(new Set(ids)), }); // Opt into row-click expansion by extending the derived config. - const expansion = useTableRowExpansion({ - ...expansionConfig, + const tree = useTableTreeData({ + ...treeConfig, hasRowClickExpansion: true, }); return (
); }, diff --git a/packages/cli/api/template/list/list.mjs b/packages/cli/api/template/list/list.mjs index 76c29a537e57..35d050c73a40 100644 --- a/packages/cli/api/template/list/list.mjs +++ b/packages/cli/api/template/list/list.mjs @@ -27,8 +27,6 @@ export function templateList(templates, options = {}) { data: filtered.map(t => ({ id: t.dirName, name: t.name, - // `displayName` retained for back-compat with existing consumers. - displayName: t.name, description: t.description, type: t.type, package: pkgOf(t), diff --git a/packages/cli/api/template/skeleton/skeleton.mjs b/packages/cli/api/template/skeleton/skeleton.mjs index d75b83758b40..6eed027c75b5 100644 --- a/packages/cli/api/template/skeleton/skeleton.mjs +++ b/packages/cli/api/template/skeleton/skeleton.mjs @@ -42,7 +42,6 @@ const SPATIAL_PROPS = [ 'rowGap', 'columnGap', 'columns', - 'minChildWidth', 'hasDivider', 'defaultHasDividers', 'variant', diff --git a/packages/cli/api/template/template.type.mjs b/packages/cli/api/template/template.type.mjs index 85899f4e9a72..2e07e6c67638 100644 --- a/packages/cli/api/template/template.type.mjs +++ b/packages/cli/api/template/template.type.mjs @@ -26,7 +26,6 @@ * @typedef {object} TemplateListEntry * @property {string} id - Stable template id (relative path under the templates root, minus the .doc.* suffix). * @property {string} name - * @property {string} displayName - (deprecated) Alias of `name`, retained for back-compat. * @property {string} description * @property {'page' | 'block'} type * @property {string} package - Owning package; core (built-in) templates report '@astryxdesign/core'. diff --git a/packages/cli/assets/templates/blocks/components/Table/TableRowExpansionTable.doc.mjs b/packages/cli/assets/templates/blocks/components/Table/TableRowExpansionTable.doc.mjs deleted file mode 100644 index b09ac37d14b6..000000000000 --- a/packages/cli/assets/templates/blocks/components/Table/TableRowExpansionTable.doc.mjs +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. - -/** @type {import('@astryxdesign/cli/authoring').TemplateDoc} */ -export const doc = { - type: 'block', - exampleFor: 'useTableRowExpansion', - name: 'useTableRowExpansion — Tree Table', - displayName: 'useTableRowExpansion — Tree Table', - description: - 'A tree table using useTableRowExpansion with inherited columns. Child rows use the same columns as parents, indented by depth. Click the chevron or right-click to expand/collapse.', - isReady: true, - aspectRatio: 16 / 9, - componentsUsed: ['Table'], -}; diff --git a/packages/cli/assets/templates/blocks/components/Table/TableRowExpansionTable.tsx b/packages/cli/assets/templates/blocks/components/Table/TableRowExpansionTable.tsx deleted file mode 100644 index d404f1c50951..000000000000 --- a/packages/cli/assets/templates/blocks/components/Table/TableRowExpansionTable.tsx +++ /dev/null @@ -1,99 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. - -'use client'; - -import {useState} from 'react'; -import { - Table, - useTableRowExpansion, - useTableRowExpansionState, - proportional, - pixel, -} from '@astryxdesign/core/Table'; - -interface FileNode extends Record { - id: string; - name: string; - type: 'folder' | 'file'; - size: string; - children?: FileNode[]; -} - -const fileTree: FileNode[] = [ - { - id: 'src', - name: 'src', - type: 'folder', - size: '—', - children: [ - { - id: 'src/components', - name: 'components', - type: 'folder', - size: '—', - children: [ - { - id: 'src/components/Button.tsx', - name: 'Button.tsx', - type: 'file', - size: '4.2 KB', - children: [], - }, - { - id: 'src/components/Table.tsx', - name: 'Table.tsx', - type: 'file', - size: '12.8 KB', - children: [], - }, - ], - }, - { - id: 'src/index.ts', - name: 'index.ts', - type: 'file', - size: '0.4 KB', - children: [], - }, - ], - }, - { - id: 'package.json', - name: 'package.json', - type: 'file', - size: '1.8 KB', - children: [], - }, -]; - -const columns = [ - {key: 'name', header: 'Name', width: proportional(2)}, - {key: 'type', header: 'Type', width: pixel(80)}, - {key: 'size', header: 'Size', width: pixel(90)}, -]; - -export default function TableRowExpansionTable() { - const [expandedKeys, setExpandedKeys] = useState>( - new Set(['src']), - ); - - const {data, expansionConfig} = useTableRowExpansionState({ - baseData: fileTree, - getChildren: item => item.children ?? [], - getRowKey: item => item.id, - expandedKeys, - setExpandedKeys, - }); - - const expansion = useTableRowExpansion(expansionConfig); - - return ( -
- ); -} diff --git a/packages/cli/assets/templates/pages/shell-top-nav/page.tsx b/packages/cli/assets/templates/pages/shell-top-nav/page.tsx index df7a28e30dd8..e36dac56137d 100644 --- a/packages/cli/assets/templates/pages/shell-top-nav/page.tsx +++ b/packages/cli/assets/templates/pages/shell-top-nav/page.tsx @@ -208,7 +208,7 @@ export default function ShellTopNav() { {[0, 1, 2].map(section => ( - + {CATEGORY_TILES.map(tile => ( diff --git a/packages/cli/clients/cli/commands/component.test.mjs b/packages/cli/clients/cli/commands/component.test.mjs index 9fb692fec43a..65e8189956df 100644 --- a/packages/cli/clients/cli/commands/component.test.mjs +++ b/packages/cli/clients/cli/commands/component.test.mjs @@ -6,7 +6,6 @@ import * as path from 'node:path'; import * as os from 'node:os'; import { discoverComponents, - discoverExternalComponents, discoverExternalComponentsGrouped, findExternalComponentDoc, findComponentReadme, @@ -387,71 +386,6 @@ describe('findClosestComponents', () => { }); }); -describe('discoverExternalComponents', () => { - it('finds .doc.mjs files in the docs directory', () => { - const docsDir = path.join(tmpDir, 'src'); - const compDir = path.join(docsDir, 'Employee'); - fs.mkdirSync(compDir, {recursive: true}); - fs.writeFileSync(path.join(compDir, 'EmployeeHoverCard.doc.mjs'), ''); - fs.writeFileSync(path.join(compDir, 'EmployeeLink.doc.mjs'), ''); - - const result = discoverExternalComponents(docsDir); - expect(result).toEqual(['EmployeeHoverCard', 'EmployeeLink']); - }); - - it('scans nested directories recursively', () => { - const docsDir = path.join(tmpDir, 'src'); - const deepDir = path.join(docsDir, 'a', 'b', 'c'); - fs.mkdirSync(deepDir, {recursive: true}); - fs.writeFileSync(path.join(deepDir, 'DeepComponent.doc.mjs'), ''); - - const result = discoverExternalComponents(docsDir); - expect(result).toEqual(['DeepComponent']); - }); - - it('ignores non-.doc.mjs files', () => { - const docsDir = path.join(tmpDir, 'src'); - fs.mkdirSync(docsDir, {recursive: true}); - fs.writeFileSync(path.join(docsDir, 'Foo.doc.mjs'), ''); - fs.writeFileSync(path.join(docsDir, 'Foo.tsx'), ''); - fs.writeFileSync(path.join(docsDir, 'README.md'), ''); - fs.writeFileSync(path.join(docsDir, 'index.mjs'), ''); - - const result = discoverExternalComponents(docsDir); - expect(result).toEqual(['Foo']); - }); - - it('skips node_modules and __tests__ directories', () => { - const docsDir = path.join(tmpDir, 'src'); - const nmDir = path.join(docsDir, 'node_modules', 'dep'); - const testDir = path.join(docsDir, '__tests__'); - fs.mkdirSync(nmDir, {recursive: true}); - fs.mkdirSync(testDir, {recursive: true}); - fs.writeFileSync(path.join(nmDir, 'Hidden.doc.mjs'), ''); - fs.writeFileSync(path.join(testDir, 'TestOnly.doc.mjs'), ''); - fs.writeFileSync(path.join(docsDir, 'Visible.doc.mjs'), ''); - - const result = discoverExternalComponents(docsDir); - expect(result).toEqual(['Visible']); - }); - - it('returns empty array for nonexistent directory', () => { - const result = discoverExternalComponents(path.join(tmpDir, 'nope')); - expect(result).toEqual([]); - }); - - it('returns sorted results', () => { - const docsDir = path.join(tmpDir, 'src'); - fs.mkdirSync(docsDir, {recursive: true}); - fs.writeFileSync(path.join(docsDir, 'Zebra.doc.mjs'), ''); - fs.writeFileSync(path.join(docsDir, 'Alpha.doc.mjs'), ''); - fs.writeFileSync(path.join(docsDir, 'Middle.doc.mjs'), ''); - - const result = discoverExternalComponents(docsDir); - expect(result).toEqual(['Alpha', 'Middle', 'Zebra']); - }); -}); - describe('discoverExternalComponentsGrouped', () => { it('reads group: from doc files and groups components', () => { const docsDir = path.join(tmpDir, 'src'); diff --git a/packages/cli/clients/cli/commands/component/index.mjs b/packages/cli/clients/cli/commands/component/index.mjs index a447136bbfdb..fa727a7697f3 100644 --- a/packages/cli/clients/cli/commands/component/index.mjs +++ b/packages/cli/clients/cli/commands/component/index.mjs @@ -280,7 +280,7 @@ export function registerComponent(program) { // Re-export lib functions for backward compatibility // (agent-docs.mjs, tests, and generate-skill-doc.sh import from here) -export {discoverComponents, discoverExternalComponents, discoverExternalComponentsGrouped, findComponentReadme, findComponentSource, findExternalComponentDoc, resolveImportPath} from '../../../../foundation/discovery/component-discovery.mjs'; +export {discoverComponents, discoverExternalComponentsGrouped, findComponentReadme, findComponentSource, findExternalComponentDoc, resolveImportPath} from '../../../../foundation/discovery/component-discovery.mjs'; export {discoverExternalPackages} from '../../../../foundation/fs/paths.mjs'; export {loadDocs} from '../../../../foundation/discovery/component-loader.mjs'; export {formatFull, formatCompact, formatBrief, formatProps, formatBriefAll} from '../../lib/component-format.mjs'; diff --git a/packages/cli/foundation/discovery/component-discovery.mjs b/packages/cli/foundation/discovery/component-discovery.mjs index 886c4cc79af1..caf99d8e8009 100644 --- a/packages/cli/foundation/discovery/component-discovery.mjs +++ b/packages/cli/foundation/discovery/component-discovery.mjs @@ -396,37 +396,6 @@ export function resolveImportPath(coreDir, componentName) { // ── External package discovery ─────────────────────────────────────── -/** - * Discover components from an external package's docs directory. - * Scans for *.doc.mjs files and returns their names as a flat array. - * - * @deprecated Use discoverExternalComponentsGrouped for group-aware discovery. - * @param {string} docsDir - * @returns {string[]} - */ -export function discoverExternalComponents(docsDir) { - if (!fs.existsSync(docsDir)) return []; - /** @type {string[]} */ - const components = []; - - /** @param {string} dirPath */ - function scanDir(dirPath) { - const entries = fs.readdirSync(dirPath, {withFileTypes: true}); - for (const entry of entries) { - if (entry.name === 'node_modules' || entry.name === '__tests__') continue; - const fullPath = path.join(dirPath, entry.name); - if (entry.isDirectory()) { - scanDir(fullPath); - } else if (entry.name.endsWith('.doc.mjs')) { - components.push(entry.name.replace('.doc.mjs', '')); - } - } - } - - scanDir(docsDir); - return components.sort(); -} - /** * Discover components from an external package's docs directory, * reading `group:` fields from each .doc.mjs for subcategories. diff --git a/packages/cli/foundation/discovery/component-discovery.test.mjs b/packages/cli/foundation/discovery/component-discovery.test.mjs index a87a58d4bb3c..cbe7c9f1034c 100644 --- a/packages/cli/foundation/discovery/component-discovery.test.mjs +++ b/packages/cli/foundation/discovery/component-discovery.test.mjs @@ -19,7 +19,6 @@ import { findComponentReadme, findComponentSource, resolveImportPath, - discoverExternalComponents, discoverExternalComponentsGrouped, findExternalComponentDoc, discoverIntegrationComponents, @@ -172,9 +171,6 @@ describe('external package discovery', () => { return docs; } - it('discoverExternalComponents returns a sorted flat list (deprecated)', () => { - expect(discoverExternalComponents(buildExternalDocs())).toEqual(['AppShell', 'Diff', 'Secret', 'SideNav']); - }); it('discoverExternalComponentsGrouped groups + drops hidden', () => { expect(discoverExternalComponentsGrouped(buildExternalDocs())).toEqual({ @@ -190,7 +186,6 @@ describe('external package discovery', () => { }); it('returns empty for a missing docs dir (guarded)', () => { - expect(discoverExternalComponents('/no/such/dir')).toEqual([]); expect(discoverExternalComponentsGrouped('/no/such/dir')).toEqual({}); expect(findExternalComponentDoc('/no/such/dir', 'X')).toBeNull(); }); diff --git a/packages/core/src/Calendar/Calendar.test.tsx b/packages/core/src/Calendar/Calendar.test.tsx index 51de968c42d7..9eb83c7d6e29 100644 --- a/packages/core/src/Calendar/Calendar.test.tsx +++ b/packages/core/src/Calendar/Calendar.test.tsx @@ -19,9 +19,13 @@ import type {CalendarHandle} from './Calendar'; import type {ISODateString} from './Calendar'; import {calendarStyles} from './styles'; import {defineTheme} from '../theme/defineTheme'; -import {generateThemeCSSFlat} from '../theme/generateThemeRules'; +import {generateThemeCSS} from '../theme/generateThemeRules'; import {__resetLiveRegionsForTest} from '../hooks/useAnnounce'; +function generateThemeTestCSS(theme: Parameters[0]) { + const {prose, component} = generateThemeCSS(theme); + return [prose, component].filter(Boolean).join('\n\n'); +} afterEach(() => { __resetLiveRegionsForTest(); }); @@ -1148,7 +1152,7 @@ describe('Calendar', () => { }, }, }); - const css = generateThemeCSSFlat(theme); + const css = generateThemeTestCSS(theme); expect(css).toContain('.astryx-calendar-day.today-only'); expect(css).toContain('.astryx-calendar-day.today-in-range'); expect(css).toContain('box-shadow: inset 0 0 0 2px var(--color-accent)'); @@ -1229,7 +1233,7 @@ describe('Calendar', () => { }, }, }); - const css = generateThemeCSSFlat(theme); + const css = generateThemeTestCSS(theme); expect(css).toContain('.astryx-calendar-nav {'); expect(css).toContain('color: var(--color-accent)'); expect(css).toContain('.astryx-calendar-nav.next'); diff --git a/packages/core/src/Calendar/index.ts b/packages/core/src/Calendar/index.ts index 41c95d653ad0..9182200b7261 100644 --- a/packages/core/src/Calendar/index.ts +++ b/packages/core/src/Calendar/index.ts @@ -38,11 +38,6 @@ export type { } from './hooks'; // Re-export calendar-specific utilities for advanced usage -export { - isSameDay, - isDateInRange, - getWeekNumber, - formatAccessibleDate, -} from './utils'; +export {isSameDay, isDateInRange, getWeekNumber} from './utils'; // Re-export theme styles for customization diff --git a/packages/core/src/Calendar/utils.ts b/packages/core/src/Calendar/utils.ts index d3c06abd0313..ff14b8d3e06a 100644 --- a/packages/core/src/Calendar/utils.ts +++ b/packages/core/src/Calendar/utils.ts @@ -7,8 +7,6 @@ * @position Shared utilities; used by Calendar, CalendarMonthGrid, CalendarDayCell */ -import {plainDateFormat, DATE_FORMAT_WITH_WEEKDAY} from '../utils/plainDate'; - export { type PlainDate, plainDateFromISO as parseISO, @@ -17,12 +15,3 @@ export { plainDateIsInRange as isDateInRange, plainDateGetWeekNumber as getWeekNumber, } from '../utils/plainDate'; - -export {plainDateFormat, DATE_FORMAT_WITH_WEEKDAY}; - -/** @deprecated Use `plainDateFormat(pd, DATE_FORMAT_WITH_WEEKDAY)` instead. */ -export function formatAccessibleDate( - ...args: Parameters -): string { - return plainDateFormat(args[0], DATE_FORMAT_WITH_WEEKDAY); -} diff --git a/packages/core/src/CommandPalette/CommandPaletteGroup.test.tsx b/packages/core/src/CommandPalette/CommandPaletteGroup.test.tsx index 0f0af6cd7071..913db8dae422 100644 --- a/packages/core/src/CommandPalette/CommandPaletteGroup.test.tsx +++ b/packages/core/src/CommandPalette/CommandPaletteGroup.test.tsx @@ -10,8 +10,12 @@ import {describe, it, expect} from 'vitest'; import {render, screen} from '@testing-library/react'; import {CommandPaletteGroup} from './CommandPaletteGroup'; import {defineTheme} from '../theme/defineTheme'; -import {generateThemeCSSFlat} from '../theme/generateThemeRules'; +import {generateThemeCSS} from '../theme/generateThemeRules'; +function generateThemeTestCSS(theme: Parameters[0]) { + const {prose, component} = generateThemeCSS(theme); + return [prose, component].filter(Boolean).join('\n\n'); +} describe('CommandPaletteGroup', () => { it('renders heading', () => { render( @@ -116,7 +120,7 @@ describe('CommandPaletteGroup', () => { }, }, }); - const css = generateThemeCSSFlat(theme); + const css = generateThemeTestCSS(theme); expect(css).toContain('.astryx-command-palette-group-heading {'); expect(css).toContain('padding-block: var(--spacing-2)'); expect(css).toContain('font-weight: var(--font-weight-bold)'); diff --git a/packages/core/src/DateInput/DateInput.test.tsx b/packages/core/src/DateInput/DateInput.test.tsx index ef2ce89236cb..88d40c00a225 100644 --- a/packages/core/src/DateInput/DateInput.test.tsx +++ b/packages/core/src/DateInput/DateInput.test.tsx @@ -24,8 +24,12 @@ import {Icon} from '../Icon'; import {InputGroup} from '../InputGroup'; import {InputGroupText} from '../InputGroup/InputGroupText'; import {defineTheme} from '../theme/defineTheme'; -import {generateThemeCSSFlat} from '../theme/generateThemeRules'; +import {generateThemeCSS} from '../theme/generateThemeRules'; +function generateThemeTestCSS(theme: Parameters[0]) { + const {prose, component} = generateThemeCSS(theme); + return [prose, component].filter(Boolean).join('\n\n'); +} describe('DateInput', () => { it('renders with label', () => { render( {}} />); @@ -1073,7 +1077,7 @@ describe('DateInput clear icon theme target', () => { }, }, }); - const css = generateThemeCSSFlat(theme); + const css = generateThemeTestCSS(theme); expect(css).toContain('.astryx-date-input-clear-icon {'); expect(css).toContain('width: 12px'); expect(css).toContain('height: 12px'); @@ -1165,7 +1169,7 @@ describe('DateInput calendar-toggle icon theme target', () => { }, }, }); - const css = generateThemeCSSFlat(theme); + const css = generateThemeTestCSS(theme); expect(css).toContain('.astryx-date-input-toggle-icon {'); expect(css).toContain('width: 14px'); expect(css).toContain('height: 14px'); diff --git a/packages/core/src/DateRangeInput/DateRangeInput.test.tsx b/packages/core/src/DateRangeInput/DateRangeInput.test.tsx index e6baa64d1d08..f3436f62547d 100644 --- a/packages/core/src/DateRangeInput/DateRangeInput.test.tsx +++ b/packages/core/src/DateRangeInput/DateRangeInput.test.tsx @@ -12,6 +12,11 @@ import {describe, it, expect, vi, beforeEach} from 'vitest'; import {render, screen, fireEvent, waitFor} from '@testing-library/react'; import userEvent from '@testing-library/user-event'; + +function generateThemeTestCSS(theme: Parameters[0]) { + const {prose, component} = generateThemeCSS(theme); + return [prose, component].filter(Boolean).join('\n\n'); +} // getButton/queryButton instead of getByRole('button', {name}): the closed // popover keeps a two-month Calendar (~85 role=button nodes) mounted, which // made every role+name query compute ~85 accessible names through jsdom's @@ -21,7 +26,7 @@ import {DateRangeInput} from './DateRangeInput'; import type {DateRange} from './DateRangeInput'; import {Icon} from '../Icon'; import {defineTheme} from '../theme/defineTheme'; -import {generateThemeCSSFlat} from '../theme/generateThemeRules'; +import {generateThemeCSS} from '../theme/generateThemeRules'; describe('DateRangeInput', () => { it('renders with label', () => { @@ -603,7 +608,7 @@ describe('DateRangeInput icon theme targets', () => { }, }, }); - const css = generateThemeCSSFlat(theme); + const css = generateThemeTestCSS(theme); expect(css).toContain('.astryx-date-range-input-clear-icon'); expect(css).toContain('.astryx-date-range-input-toggle-icon'); expect(css).toContain(':hover'); diff --git a/packages/core/src/DateTimeInput/DateTimeInput.test.tsx b/packages/core/src/DateTimeInput/DateTimeInput.test.tsx index 91589c4ac267..098026f73193 100644 --- a/packages/core/src/DateTimeInput/DateTimeInput.test.tsx +++ b/packages/core/src/DateTimeInput/DateTimeInput.test.tsx @@ -16,8 +16,12 @@ import {getButton, queryButton} from '../__tests__/fastRoleQueries'; import {DateTimeInput} from './DateTimeInput'; import type {ISODateTimeString} from './DateTimeInput'; import {defineTheme} from '../theme/defineTheme'; -import {generateThemeCSSFlat} from '../theme/generateThemeRules'; +import {generateThemeCSS} from '../theme/generateThemeRules'; +function generateThemeTestCSS(theme: Parameters[0]) { + const {prose, component} = generateThemeCSS(theme); + return [prose, component].filter(Boolean).join('\n\n'); +} describe('DateTimeInput', () => { it('renders with label', () => { render( {}} />); @@ -917,7 +921,7 @@ describe('DateTimeInput', () => { }, }, }); - const css = generateThemeCSSFlat(theme); + const css = generateThemeTestCSS(theme); expect(css).toContain('.astryx-date-time-input-date-segment {'); expect(css).toContain('.astryx-date-time-input-date-segment.lg'); diff --git a/packages/core/src/Dialog/Dialog.doc.mjs b/packages/core/src/Dialog/Dialog.doc.mjs index cd49804eed13..ce8b65dad813 100644 --- a/packages/core/src/Dialog/Dialog.doc.mjs +++ b/packages/core/src/Dialog/Dialog.doc.mjs @@ -76,9 +76,7 @@ export const docs = { type: 'DialogPosition', description: 'Static position for the dialog; centered by default when omitted. ' + - 'Prefer logical `start`/`end` (they map correctly under RTL) over the ' + - 'deprecated physical `left`/`right`, which do not mirror. Logical ' + - 'wins if both are set.', + 'Use logical `start`/`end` for inline offsets so positioned dialogs mirror correctly under RTL.', }, { name: 'variant', diff --git a/packages/core/src/Dialog/Dialog.test.tsx b/packages/core/src/Dialog/Dialog.test.tsx index 484c9fd9d906..e78910a832ac 100644 --- a/packages/core/src/Dialog/Dialog.test.tsx +++ b/packages/core/src/Dialog/Dialog.test.tsx @@ -190,7 +190,7 @@ describe('Dialog', () => { {}} - position={{top: 100, right: 20}}> + position={{top: 100, end: 20}}> Content , ); @@ -202,7 +202,7 @@ describe('Dialog', () => { {}} - position={{top: '10vh', left: '5vw'}}> + position={{top: '10vh', start: '5vw'}}> Content , ); @@ -235,61 +235,21 @@ describe('Dialog', () => { expect(offsets.insetInlineStart).toBe('20px'); expect(offsets.insetInlineEnd).toBe('40px'); // No physical offsets requested → auto. - expect(offsets.left).toBe('auto'); - expect(offsets.right).toBe('auto'); - }); - - it('keeps physical left/right physical — they do NOT mirror', () => { - // Physical props are the visual-left / visual-right edge in BOTH LTR and - // RTL (unchanged from pre-deprecation). - const offsets = resolveDialogPositionOffsets({left: 20, right: 40}); - expect(offsets.left).toBe('20px'); - expect(offsets.right).toBe('40px'); - // insetInlineStart/End default to auto (no logical positioning requested). - expect(offsets.insetInlineStart).toBe('auto'); - expect(offsets.insetInlineEnd).toBe('auto'); }); it('combines block-axis top/bottom with an inline pair', () => { const offsets = resolveDialogPositionOffsets({top: 100, start: 12}); expect(offsets.top).toBe('100px'); expect(offsets.insetInlineStart).toBe('12px'); - // Everything unset falls back to auto — no mirroring for a logical-only - // consumer's physical slots. + // Everything unset falls back to auto. expect(offsets.bottom).toBe('auto'); - expect(offsets.left).toBe('auto'); - expect(offsets.right).toBe('auto'); expect(offsets.insetInlineEnd).toBe('auto'); }); - it('is non-breaking: physical-only offsets match pre-deprecation output', () => { - const offsets = resolveDialogPositionOffsets({top: 100, right: 20}); - expect(offsets.top).toBe('100px'); - expect(offsets.right).toBe('20px'); - expect(offsets.left).toBe('auto'); - expect(offsets.bottom).toBe('auto'); - expect(offsets.insetInlineStart).toBe('auto'); - expect(offsets.insetInlineEnd).toBe('auto'); - }); - - it('passes through string offsets (vw/vh/etc.) for both APIs', () => { + it('passes through string offsets (vw/vh/etc.) for logical offsets', () => { const logical = resolveDialogPositionOffsets({start: '5vw', end: '10%'}); expect(logical.insetInlineStart).toBe('5vw'); expect(logical.insetInlineEnd).toBe('10%'); - - const physical = resolveDialogPositionOffsets({left: '5vw', top: '10vh'}); - expect(physical.left).toBe('5vw'); - expect(physical.top).toBe('10vh'); - }); - - it('forbids mixing logical and physical inline offsets at compile time', () => { - // The union type makes a mixed inline pair a type error — a single dialog - // cannot be positioned both logically and physically. These would not - // compile (kept as documentation of the enforced contract): - // @ts-expect-error start (logical) cannot combine with left (physical) - resolveDialogPositionOffsets({start: 5, left: 99}); - // @ts-expect-error end (logical) cannot combine with right (physical) - resolveDialogPositionOffsets({end: 7, right: 88}); }); }); diff --git a/packages/core/src/Dialog/Dialog.tsx b/packages/core/src/Dialog/Dialog.tsx index 55affcc9fb57..fe9ccc42be97 100644 --- a/packages/core/src/Dialog/Dialog.tsx +++ b/packages/core/src/Dialog/Dialog.tsx @@ -97,27 +97,14 @@ interface DialogBlockPosition { * Static position for a dialog. The inline axis is logical XOR physical — the * type forbids mixing the two, so a single dialog can't be positioned both ways: * - Logical `start`/`end` map to `inset-inline-*` and mirror under RTL (preferred). - * - Physical `left`/`right` are deprecated, do not mirror, and are removed in a - * future major. * Block-axis `top`/`bottom` may be combined with either. */ -export type DialogPosition = - | (DialogBlockPosition & { - /** Logical inline-start offset (`inset-inline-start`); mirrors under RTL. */ - start?: number | string; - /** Logical inline-end offset (`inset-inline-end`); mirrors under RTL. */ - end?: number | string; - left?: never; - right?: never; - }) - | (DialogBlockPosition & { - /** @deprecated Use `start`. Physical (never mirrors); removed in a future major. */ - left?: number | string; - /** @deprecated Use `end`. Physical (never mirrors); removed in a future major. */ - right?: number | string; - start?: never; - end?: never; - }); +export interface DialogPosition extends DialogBlockPosition { + /** Logical inline-start offset (`inset-inline-start`); mirrors under RTL. */ + start?: number | string; + /** Logical inline-end offset (`inset-inline-end`); mirrors under RTL. */ + end?: number | string; +} const enterDirectional = stylex.keyframes({ from: { @@ -223,9 +210,7 @@ const dynamicStyles = stylex.create({ top: string, insetInlineStart: string, insetInlineEnd: string, - right: string, bottom: string, - left: string, ) => ({ // Assigns pre-resolved offsets from resolveDialogPositionOffsets(). This // literal has no logic — StyleX can't analyze a helper, so the values @@ -235,11 +220,7 @@ const dynamicStyles = stylex.create({ top, insetInlineStart, insetInlineEnd, - // eslint-disable-next-line @astryx/no-physical-properties -- deprecated consumer-facing DialogPosition.right; physical by contract, superseded by logical `end` - right, bottom, - // eslint-disable-next-line @astryx/no-physical-properties -- deprecated consumer-facing DialogPosition.left; physical by contract, superseded by logical `start` - left, }), }); @@ -252,9 +233,8 @@ function formatPosition(value: number | string): string { /** * Map a {@link DialogPosition} to resolved CSS offsets. Logical `start`/`end` - * become `inset-inline-*` (mirror under RTL); physical `left`/`right` stay - * physical. The union type guarantees at most one inline pair is set, so no - * precedence is needed — each unset offset falls back to `auto`. + * become `inset-inline-*` (mirror under RTL); each unset offset falls back to + * `auto`. * * Not re-exported from the package; internal to Dialog. Directly unit-tested * so the mapping is verified without StyleX class compilation. @@ -262,7 +242,7 @@ function formatPosition(value: number | string): string { * @see DialogPosition */ export function resolveDialogPositionOffsets(position: DialogPosition) { - const {top, bottom, start, end, left, right} = position; + const {top, bottom, start, end} = position; return { top: top !== undefined ? formatPosition(top) : 'auto', @@ -270,9 +250,6 @@ export function resolveDialogPositionOffsets(position: DialogPosition) { // Logical offsets mirror under RTL (preferred replacements). insetInlineStart: start !== undefined ? formatPosition(start) : 'auto', insetInlineEnd: end !== undefined ? formatPosition(end) : 'auto', - // Deprecated physical offsets never mirror. - left: left !== undefined ? formatPosition(left) : 'auto', - right: right !== undefined ? formatPosition(right) : 'auto', }; } @@ -675,9 +652,7 @@ export function Dialog({ o.top, o.insetInlineStart, o.insetInlineEnd, - o.right, o.bottom, - o.left, ); })(), isFullscreen && styles.fullscreen, diff --git a/packages/core/src/Grid/Grid.doc.mjs b/packages/core/src/Grid/Grid.doc.mjs index b520a8682b3b..c64bb982dc03 100644 --- a/packages/core/src/Grid/Grid.doc.mjs +++ b/packages/core/src/Grid/Grid.doc.mjs @@ -33,11 +33,6 @@ export const docs = { type: "number | {minWidth: number, max?: number, repeat?: 'fill' | 'fit'}", description: 'Column configuration. Use a number for fixed columns (e.g. `columns={3}`). Use an object for responsive columns: `minWidth` sets the minimum column width in px, `repeat` controls track behavior (`"fill"` preserves empty tracks for consistent widths, `"fit"` collapses empty tracks so items stretch; defaults to `"fill"`), and `max` caps the maximum number of columns.', }, - { - name: 'minChildWidth', - type: 'number', - description: 'Deprecated: use `columns={{minWidth: 280}}` instead. Minimum item width in px; enables responsive auto-fit.', - }, { name: 'width', type: 'SizeValue', diff --git a/packages/core/src/Grid/Grid.tsx b/packages/core/src/Grid/Grid.tsx index 20da4e9067c3..5e153a737737 100644 --- a/packages/core/src/Grid/Grid.tsx +++ b/packages/core/src/Grid/Grid.tsx @@ -63,15 +63,6 @@ export interface GridProps extends BaseProps { */ columns?: GridColumns; - /** - * Minimum width of each grid item in pixels. - * Enables responsive auto-fit behavior. - * - * @deprecated Use `columns={{minWidth: 280}}` instead. - * @default 0 (disabled) - */ - minChildWidth?: number; - /** * Width of the grid container. * Numbers are treated as pixels, strings are used as-is (e.g., '100%'). @@ -384,7 +375,6 @@ function buildCappedTemplate( */ export function Grid({ columns, - minChildWidth = 0, rowHeight, width, height, @@ -421,20 +411,6 @@ export function Grid({ } else { gridTemplateColumns = `repeat(${repeatMode}, minmax(${columns.minWidth}px, 1fr))`; } - } else if (minChildWidth > 0) { - // Deprecated path: minChildWidth uses auto-fit for backward compat - const numColumns = typeof columns === 'number' ? columns : 0; - if (numColumns > 0) { - gridTemplateColumns = buildCappedTemplate( - minChildWidth, - numColumns, - 'auto-fit', - gap, - columnGap, - ); - } else { - gridTemplateColumns = `repeat(auto-fit, minmax(${minChildWidth}px, 1fr))`; - } } else if (typeof columns === 'number' && columns > 0) { // Fixed columns mode gridTemplateColumns = `repeat(${columns}, 1fr)`; diff --git a/packages/core/src/MultiSelector/MultiSelector.test.tsx b/packages/core/src/MultiSelector/MultiSelector.test.tsx index 4f90b9760034..f2608862c1b9 100644 --- a/packages/core/src/MultiSelector/MultiSelector.test.tsx +++ b/packages/core/src/MultiSelector/MultiSelector.test.tsx @@ -22,8 +22,12 @@ import {MultiSelector} from './MultiSelector'; import {Icon} from '../Icon'; import {__resetLiveRegionsForTest} from '../hooks/useAnnounce'; import {defineTheme} from '../theme/defineTheme'; -import {generateThemeCSSFlat} from '../theme/generateThemeRules'; +import {generateThemeCSS} from '../theme/generateThemeRules'; +function generateThemeTestCSS(theme: Parameters[0]) { + const {prose, component} = generateThemeCSS(theme); + return [prose, component].filter(Boolean).join('\n\n'); +} // Module-level constants to satisfy @eslint-react/no-unstable-default-props. const ANNOUNCE_OPTIONS = ['Apple', 'Banana', 'Orange'] as const; const EMPTY_VALUE: string[] = []; @@ -1560,7 +1564,7 @@ describe('MultiSelector clear icon theme target', () => { }, }, }); - const css = generateThemeCSSFlat(theme); + const css = generateThemeTestCSS(theme); expect(css).toContain('.astryx-multi-selector-clear-icon {'); expect(css).toContain('width: 12px'); expect(css).toContain('height: 12px'); @@ -1673,7 +1677,7 @@ describe('MultiSelector indicator (chevron) icon theme target', () => { }, }, }); - const css = generateThemeCSSFlat(theme); + const css = generateThemeTestCSS(theme); expect(css).toContain('.astryx-multi-selector-indicator-icon {'); expect(css).toContain('width: 14px'); expect(css).toContain('height: 14px'); diff --git a/packages/core/src/NavMenu/NavHeadingMenu.test.tsx b/packages/core/src/NavMenu/NavHeadingMenu.test.tsx index e2b3cf54b064..b52eb7207958 100644 --- a/packages/core/src/NavMenu/NavHeadingMenu.test.tsx +++ b/packages/core/src/NavMenu/NavHeadingMenu.test.tsx @@ -5,7 +5,6 @@ import {render, screen, fireEvent} from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import {NavHeadingMenu} from './NavHeadingMenu'; import {NavHeadingMenuItem} from './NavHeadingMenuItem'; -import {NavMenuItem} from './NavMenuItem'; import {NavHeadingCloseContext} from './NavMenuContext'; describe('NavHeadingMenu', () => { @@ -322,18 +321,3 @@ describe('context forwarding', () => { expect(closeMenu).toHaveBeenCalledOnce(); }); }); - -describe('NavMenuItem backward compat', () => { - it('is the same component as NavHeadingMenuItem', () => { - expect(NavMenuItem).toBe(NavHeadingMenuItem); - }); - - it('renders correctly when used as NavMenuItem', () => { - render( - - - , - ); - expect(screen.getByRole('menuitem')).toHaveTextContent('Legacy'); - }); -}); diff --git a/packages/core/src/NavMenu/NavMenuItem.tsx b/packages/core/src/NavMenu/NavMenuItem.tsx deleted file mode 100644 index 9063b92e8cb1..000000000000 --- a/packages/core/src/NavMenu/NavMenuItem.tsx +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. - -'use client'; - -/** - * @file NavMenuItem.tsx - * @input NavHeadingMenuItem - * @output Exports deprecated NavMenuItem alias - * @position Compatibility re-export; prefer NavHeadingMenuItem - */ - -import {NavHeadingMenuItem} from './NavHeadingMenuItem'; -import type {NavHeadingMenuItemProps} from './NavHeadingMenuItem'; - -/** @deprecated Use NavHeadingMenuItem instead. */ -export type NavMenuItemProps = NavHeadingMenuItemProps; - -/** @deprecated Use NavHeadingMenuItem instead. */ -export const NavMenuItem = NavHeadingMenuItem; diff --git a/packages/core/src/NavMenu/index.ts b/packages/core/src/NavMenu/index.ts index ae46ba886fa9..799deddfb659 100644 --- a/packages/core/src/NavMenu/index.ts +++ b/packages/core/src/NavMenu/index.ts @@ -16,7 +16,3 @@ export type { NavHeadingCloseContextValue, NavHeadingMenuSize, } from './NavMenuContext'; - -// Backward compat — use NavHeadingMenuItem instead. -export {NavMenuItem} from './NavMenuItem'; -export type {NavMenuItemProps} from './NavMenuItem'; diff --git a/packages/core/src/Selector/Selector.test.tsx b/packages/core/src/Selector/Selector.test.tsx index b14f246929a0..c59683dde462 100644 --- a/packages/core/src/Selector/Selector.test.tsx +++ b/packages/core/src/Selector/Selector.test.tsx @@ -24,8 +24,12 @@ import {Icon} from '../Icon'; import {InputGroup, InputGroupText} from '../InputGroup'; import {__resetLiveRegionsForTest} from '../hooks/useAnnounce'; import {defineTheme} from '../theme/defineTheme'; -import {generateThemeCSSFlat} from '../theme/generateThemeRules'; +import {generateThemeCSS} from '../theme/generateThemeRules'; +function generateThemeTestCSS(theme: Parameters[0]) { + const {prose, component} = generateThemeCSS(theme); + return [prose, component].filter(Boolean).join('\n\n'); +} function politeRegion(): HTMLElement | null { return document.querySelector('[data-astryx-live-region="polite"]'); } @@ -1371,7 +1375,7 @@ describe('Selector clear icon theme target', () => { }, }, }); - const css = generateThemeCSSFlat(theme); + const css = generateThemeTestCSS(theme); expect(css).toContain('.astryx-selector-clear-icon {'); expect(css).toContain('width: 12px'); expect(css).toContain('height: 12px'); @@ -1465,7 +1469,7 @@ describe('Selector indicator (chevron) icon theme target', () => { }, }, }); - const css = generateThemeCSSFlat(theme); + const css = generateThemeTestCSS(theme); expect(css).toContain('.astryx-selector-indicator-icon {'); expect(css).toContain('width: 14px'); expect(css).toContain('height: 14px'); diff --git a/packages/core/src/Switch/Switch.doc.mjs b/packages/core/src/Switch/Switch.doc.mjs index 6be6eb55c921..17107b180531 100644 --- a/packages/core/src/Switch/Switch.doc.mjs +++ b/packages/core/src/Switch/Switch.doc.mjs @@ -141,7 +141,7 @@ export const docs = { name: 'labelSpacing', type: "'hug' | 'spread'", description: - 'Spacing behavior between label and switch. "hug" places them next to each other; "spread" pushes them to opposite ends of the container (full width). "default" is a deprecated alias for "hug".', + 'Spacing behavior between label and switch. "hug" places them next to each other; "spread" pushes them to opposite ends of the container (full width).', default: "'hug'", }, { @@ -429,6 +429,6 @@ export const docsDense = { labelTooltip: 'Tooltip text in info icon at label end.', labelPosition: 'Which side label appears; "start" places before switch.', labelSpacing: - 'Spacing behavior; "hug" places next to each other, "spread" pushes to opposite ends (full width). "default" is deprecated alias for "hug".', + 'Spacing behavior; "hug" places next to each other, "spread" pushes to opposite ends (full width).', }, }; diff --git a/packages/core/src/Switch/Switch.test.tsx b/packages/core/src/Switch/Switch.test.tsx index 161fd7a96c4f..03954248803c 100644 --- a/packages/core/src/Switch/Switch.test.tsx +++ b/packages/core/src/Switch/Switch.test.tsx @@ -619,31 +619,6 @@ describe('Switch', () => { expect(getField(explicit)).not.toHaveAttribute('data-label-spacing'); expect(getField(explicit).className).toBe(getField(implicit).className); }); - - it("treats the deprecated 'default' value as an alias for hug", () => { - const {container: hug} = render( - {}} - labelSpacing="hug" - />, - ); - const {container: deprecated} = render( - {}} - labelSpacing="default" - />, - ); - expect(getField(deprecated)).not.toHaveAttribute('data-label-spacing'); - expect(getField(deprecated).className).toBe(getField(hug).className); - // The switch row keeps the hug layout, not the spread justification. - expect(getField(deprecated).firstElementChild?.className).toBe( - getField(hug).firstElementChild?.className, - ); - }); }); describe('form participation', () => { it('submits under htmlName when on', () => { diff --git a/packages/core/src/Switch/Switch.tsx b/packages/core/src/Switch/Switch.tsx index 2e0b4fac8cf4..9d0716a5d30b 100644 --- a/packages/core/src/Switch/Switch.tsx +++ b/packages/core/src/Switch/Switch.tsx @@ -284,11 +284,7 @@ const styles = stylex.create({ export type SwitchLabelPosition = 'start' | 'end'; -export type SwitchLabelSpacing = - | 'hug' - | 'spread' - /** @deprecated Use `'hug'` instead. */ - | 'default'; +export type SwitchLabelSpacing = 'hug' | 'spread'; export interface SwitchProps extends Omit { /** Ref forwarded to the root element */ @@ -402,8 +398,6 @@ export interface SwitchProps extends Omit { * Spacing behavior between label and switch. * - 'hug': Label and switch are positioned next to each other * - 'spread': Label and switch are pushed to opposite ends - * - * 'default' is a deprecated alias for 'hug'. * @default 'hug' */ labelSpacing?: SwitchLabelSpacing; @@ -482,10 +476,6 @@ export function Switch({ const isOn = optimisticValue === true; - // 'default' is a deprecated alias for 'hug' (#2889). - const resolvedLabelSpacing: SwitchLabelSpacing = - labelSpacing === 'default' ? 'hug' : labelSpacing; - // Disabled-reason tooltip. Disabled controls swallow pointer events, so the // tooltip listeners attach to the switch row (which already exists) and the // native checkbox stays perceivable via aria-disabled instead of the disabled @@ -617,8 +607,7 @@ export function Switch({ {...mergeProps( themeProps('switch-field', { labelPosition: labelPosition !== 'end' ? labelPosition : undefined, - labelSpacing: - resolvedLabelSpacing !== 'hug' ? resolvedLabelSpacing : undefined, + labelSpacing: labelSpacing !== 'hug' ? labelSpacing : undefined, }), stylex.props(width != null && dynamicWidthStyles.width(width), xstyle), className, @@ -637,7 +626,7 @@ export function Switch({ }} {...stylex.props( styles.container, - resolvedLabelSpacing === 'spread' && styles.containerSpread, + labelSpacing === 'spread' && styles.containerSpread, !isDisabled && switchScope, )}> {' '} diff --git a/packages/core/src/Table/BaseTable.tsx b/packages/core/src/Table/BaseTable.tsx index 43cb126f537a..fb18469f5c01 100644 --- a/packages/core/src/Table/BaseTable.tsx +++ b/packages/core/src/Table/BaseTable.tsx @@ -331,7 +331,6 @@ function BaseTableInner>({ idKey, plugins: pluginsProp, children, - tableProps: userTableProps, textOverflow = 'wrap', scrollWrapper: ScrollWrapper, emptyState, @@ -394,7 +393,7 @@ function BaseTableInner>({ // --- Plugin pipeline: table --- const tableRenderProps = applyPlugins(plugins, p => p.transformTable, { - htmlProps: {...userTableProps}, + htmlProps: {}, xstyle: children ? [styles.table, styles.tableAutoLayout] : [styles.table], } satisfies TableRenderProps); @@ -498,8 +497,8 @@ function BaseTableInner>({ const hasData = data != null && data.length > 0; const hasColumns = resolvedColumns.length > 0; - // Style precedence: deprecated tableProps.style < consumer style < the - // computed column min-width (structural — derived from column defs, so it + // Style precedence: consumer style < the computed column min-width + // (structural — derived from column defs, so it // must win when present; when absent, a consumer minWidth survives). const tableStyle: React.CSSProperties = { ...tableRenderProps.htmlProps.style, diff --git a/packages/core/src/Table/Table.test.tsx b/packages/core/src/Table/Table.test.tsx index 01a78727486c..9d2eb01dfcac 100644 --- a/packages/core/src/Table/Table.test.tsx +++ b/packages/core/src/Table/Table.test.tsx @@ -401,20 +401,6 @@ describe('BaseTable', () => { expect(ref).toHaveBeenCalledWith(expect.any(HTMLTableElement)); }); - it('passes tableProps to the table element', () => { - render( - , - ); - expect(screen.getByRole('table')).toHaveAttribute( - 'aria-label', - 'Users table', - ); - }); - describe('root element styling props (#3679)', () => { it('applies className to the table element', () => { render(
); @@ -446,27 +432,6 @@ describe('BaseTable', () => { expect(table).toHaveAttribute('data-analytics', 'tables'); }); - it('composes with deprecated tableProps, direct props winning conflicts', () => { - render( -
, - ); - const table = screen.getByRole('table'); - expect(table.className).toContain('legacy'); - expect(table.className).toContain('direct'); - // Direct style wins the conflicting key; non-conflicting legacy survives. - expect(table.style.opacity).toBe('1'); - expect(table.style.color).toBe('red'); - }); - it('keeps the computed column min-width over a consumer style.minWidth', () => { const {tableMinWidth} = resolveColumnWidths(columns); render( @@ -483,20 +448,6 @@ describe('BaseTable', () => { expect(screen.getByRole('table').style.minWidth).toBe('10px'); }); - it('direct id and aria attributes beat the same keys in tableProps', () => { - render( -
, - ); - const table = screen.getByRole('table', {name: 'Direct'}); - expect(table.id).toBe('direct-id'); - }); - it('keeps the astryx theme classes alongside a consumer className', () => { render(
); const table = screen.getByRole('table'); diff --git a/packages/core/src/Table/index.ts b/packages/core/src/Table/index.ts index b5efeee87d33..4ce004ed540c 100644 --- a/packages/core/src/Table/index.ts +++ b/packages/core/src/Table/index.ts @@ -29,10 +29,6 @@ export {useTableStickyColumns} from './plugins/stickyColumns'; export {useTableGroupedRows} from './plugins/groupedRows'; export {useTableRowIndex} from './plugins/rowIndex'; export {useTableRowStatus} from './plugins/rowStatus'; -export { - useTableRowExpansion, - useTableRowExpansionState, -} from './plugins/rowExpansion'; export {useTableTreeData, useTableTreeState} from './plugins/tree'; export {resolveContextActions} from './tableContextMenu'; export { @@ -105,7 +101,6 @@ export type { } from './plugins/columnSettings'; export type {UseTableColumnResizeConfig} from './plugins/columnResize'; export type {UseTableStickyColumnsConfig} from './plugins/stickyColumns'; -export type {UseTableRowExpansionConfig} from './plugins/rowExpansion'; export type {UseTableRowIndexConfig} from './plugins/rowIndex'; export type { UseTableGroupedRowsConfig, diff --git a/packages/core/src/Table/plugins/groupedRows/useTableGroupedRows.tsx b/packages/core/src/Table/plugins/groupedRows/useTableGroupedRows.tsx index 4ec1b0a18235..f7dd5f182558 100644 --- a/packages/core/src/Table/plugins/groupedRows/useTableGroupedRows.tsx +++ b/packages/core/src/Table/plugins/groupedRows/useTableGroupedRows.tsx @@ -177,7 +177,7 @@ const styles = stylex.create({ * toggle, the group label, and a member count; collapsing hides that group's * data rows while keeping the header visible. * - * Mirrors {@link useTableRowExpansionState}: the consumer owns the + * Mirrors other controlled Table state helpers: the consumer owns the * `collapsedGroups` set and this hook returns `{data, plugin, idKey}` — * pass all three to `
`. * diff --git a/packages/core/src/Table/plugins/rowExpansion/index.ts b/packages/core/src/Table/plugins/rowExpansion/index.ts deleted file mode 100644 index ec9f08a7bf24..000000000000 --- a/packages/core/src/Table/plugins/rowExpansion/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. - -export { - useTableRowExpansion, - useTableRowExpansionState, -} from './useTableRowExpansion'; -export type {UseTableRowExpansionConfig} from './useTableRowExpansion'; diff --git a/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.test.tsx b/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.test.tsx deleted file mode 100644 index 2c040a8e28bd..000000000000 --- a/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.test.tsx +++ /dev/null @@ -1,263 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. - -import {describe, it, expect, vi, beforeEach} from 'vitest'; -import {render, renderHook, screen, fireEvent} from '@testing-library/react'; -import {useState} from 'react'; -import {Table} from '../../Table'; -import type {TableColumn} from '../../types'; -import { - useTableRowExpansion, - useTableRowExpansionState, -} from './useTableRowExpansion'; -import {InternationalizationProvider} from '../../../i18n'; - -// popover mock for context-menu tests -beforeEach(() => { - HTMLElement.prototype.showPopover = vi.fn(function (this: HTMLElement) { - this.setAttribute('popover-open', ''); - const event = new Event('toggle', {bubbles: false}); - Object.defineProperty(event, 'newState', {value: 'open'}); - this.dispatchEvent(event); - }); - HTMLElement.prototype.hidePopover = vi.fn(function (this: HTMLElement) { - this.removeAttribute('popover-open'); - const event = new Event('toggle', {bubbles: false}); - Object.defineProperty(event, 'newState', {value: 'closed'}); - this.dispatchEvent(event); - }); - const originalMatches = HTMLElement.prototype.matches; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (HTMLElement.prototype as any).matches = function ( - selector: string, - ): boolean { - if (selector === ':popover-open') { - return this.hasAttribute('popover-open'); - } - return originalMatches.call(this, selector); - }; -}); - -interface TreeItem extends Record { - id: string; - name: string; - children: TreeItem[]; -} - -const treeData: TreeItem[] = [ - { - id: 'a', - name: 'Folder A', - children: [ - {id: 'a1', name: 'File A1', children: []}, - {id: 'a2', name: 'File A2', children: []}, - ], - }, - { - id: 'b', - name: 'Folder B', - children: [{id: 'b1', name: 'File B1', children: []}], - }, - {id: 'c', name: 'Leaf C', children: []}, -]; - -const columns: TableColumn[] = [{key: 'name', header: 'Name'}]; - -const EMPTY_KEYS = new Set(); - -function Harness({ - initialExpanded = EMPTY_KEYS, -}: { - initialExpanded?: Set; -}) { - const [expandedKeys, setExpandedKeys] = useState(initialExpanded); - const {data, expansionConfig} = useTableRowExpansionState({ - baseData: treeData, - getChildren: item => item.children, - getRowKey: item => item.id, - expandedKeys, - setExpandedKeys, - }); - const expansion = useTableRowExpansion(expansionConfig); - return ( -
- ); -} - -describe('useTableRowExpansion', () => { - it('renders a chevron button for expandable rows', () => { - render(); - const buttons = screen.getAllByRole('button', {name: /expand row/i}); - // Folder A and Folder B are expandable (top-level with children) - expect(buttons.length).toBe(2); - }); - - it('shows child rows when expanded', () => { - render(); - expect(screen.getByText('File A1')).toBeInTheDocument(); - expect(screen.getByText('File A2')).toBeInTheDocument(); - }); - - it('hides child rows when collapsed', () => { - render(); - expect(screen.queryByText('File A1')).not.toBeInTheDocument(); - }); - - it('toggles expansion on chevron click', () => { - render(); - expect(screen.queryByText('File A1')).not.toBeInTheDocument(); - fireEvent.click(screen.getAllByRole('button', {name: /expand row/i})[0]); - expect(screen.getByText('File A1')).toBeInTheDocument(); - }); - - it('contributes a context-menu action on expandable rows', () => { - render(); - fireEvent.contextMenu(screen.getByText('Folder A')); - const items = screen.getAllByRole('menuitem', { - name: /expand row/i, - hidden: true, - }); - expect(items.length).toBeGreaterThan(0); - }); - - it('localizes the context-menu action label through the i18n catalog', () => { - render( - - - , - ); - fireEvent.contextMenu(screen.getByText('Folder A')); - const items = screen.getAllByRole('menuitem', { - name: /Développer la ligne/, - hidden: true, - }); - expect(items.length).toBeGreaterThan(0); - }); - - it('does not show chevron for leaf nodes', () => { - render(); - // Leaf C has no children - expect(screen.getByText('Leaf C')).toBeInTheDocument(); - // only 2 expand buttons (Folder A, Folder B), not 3 - expect( - screen.getAllByRole('button', {name: /expand row|collapse row/i}).length, - ).toBe(2); - }); - - it('renders an expand-all toggle in the header', () => { - render(); - expect( - screen.getByRole('button', {name: /expand all rows/i}), - ).toBeInTheDocument(); - }); - - it('expand-all toggle expands every expandable row', () => { - render(); - expect(screen.queryByText('File A1')).not.toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', {name: /expand all rows/i})); - // Both folders' children become visible. - expect(screen.getByText('File A1')).toBeInTheDocument(); - expect(screen.getByText('File A2')).toBeInTheDocument(); - expect(screen.getByText('File B1')).toBeInTheDocument(); - }); - - it('collapse-all toggle collapses every row', () => { - render(); - expect(screen.getByText('File A1')).toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', {name: /collapse all rows/i})); - expect(screen.queryByText('File A1')).not.toBeInTheDocument(); - expect(screen.queryByText('File B1')).not.toBeInTheDocument(); - }); -}); - -describe('useTableRowExpansionState cycle guard', () => { - /** A row whose children array contains the row itself (plus a real child). */ - function makeSelfReferential(): TreeItem[] { - const x: TreeItem = {id: 'x', name: 'Self', children: []}; - const y: TreeItem = {id: 'y', name: 'Leaf Y', children: []}; - x.children.push(x, y); - return [x]; - } - - /** root -> child -> grand, where grand's children point back at root. */ - function makeDeepCycle(): TreeItem[] { - const root: TreeItem = {id: 'root', name: 'Root', children: []}; - const child: TreeItem = {id: 'child', name: 'Child', children: []}; - const grand: TreeItem = {id: 'grand', name: 'Grand', children: []}; - root.children.push(child); - child.children.push(grand); - grand.children.push(root); - return [root]; - } - - function renderState(baseData: TreeItem[], expandedKeys: Set) { - const setExpandedKeys = vi.fn(); - const hook = renderHook(() => - useTableRowExpansionState({ - baseData, - getChildren: item => item.children, - getRowKey: item => item.id, - expandedKeys, - setExpandedKeys, - }), - ); - return {hook, setExpandedKeys}; - } - - it('terminates on a self-referential expanded row and flattens each key once', () => { - const {hook} = renderState(makeSelfReferential(), new Set(['x'])); - const {data, expansionConfig} = hook.result.current; - expect(data.map(item => item.id)).toEqual(['x', 'y']); - expect(expansionConfig.getDepth?.(data[0])).toBe(0); - expect(expansionConfig.getDepth?.(data[1])).toBe(1); - }); - - it('terminates on a deeper cycle back to the root and flattens each key once', () => { - const {hook} = renderState( - makeDeepCycle(), - new Set(['root', 'child', 'grand']), - ); - const {data, expansionConfig} = hook.result.current; - expect(data.map(item => item.id)).toEqual(['root', 'child', 'grand']); - expect(expansionConfig.getDepth?.(data[0])).toBe(0); - expect(expansionConfig.getDepth?.(data[1])).toBe(1); - expect(expansionConfig.getDepth?.(data[2])).toBe(2); - // The cycle guard keeps isAllExpanded computable — true, not a crash. - expect(expansionConfig.isAllExpanded).toBe(true); - }); - - it('terminates when collecting allExpandableKeys on cyclic data with nothing expanded', () => { - const {hook, setExpandedKeys} = renderState( - makeDeepCycle(), - new Set(), - ); - const {data, expansionConfig} = hook.result.current; - expect(data.map(item => item.id)).toEqual(['root']); - expansionConfig.onToggleExpandAll?.(true); - const nextKeys = setExpandedKeys.mock.calls[0][0] as Set; - expect(Array.from(nextKeys)).toEqual(['root', 'child', 'grand']); - }); - - it('re-walks a shared child under each expanded parent (ancestor-path, not visited-set, semantics)', () => { - const leaf: TreeItem = {id: 'leaf', name: 'Leaf', children: []}; - const shared: TreeItem = {id: 's', name: 'Shared', children: [leaf]}; - const p1: TreeItem = {id: 'p1', name: 'Parent 1', children: [shared]}; - const p2: TreeItem = {id: 'p2', name: 'Parent 2', children: [shared]}; - const {hook} = renderState([p1, p2], new Set(['p1', 'p2', 's'])); - // 's' is on neither parent's ancestor path, so it must flatten under - // both — the guard only skips true cycles, matching pre-guard behavior - // for acyclic (DAG-shaped) data. - const {data} = hook.result.current; - expect(data.map(item => item.id)).toEqual([ - 'p1', - 's', - 'leaf', - 'p2', - 's', - 'leaf', - ]); - }); -}); diff --git a/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx b/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx deleted file mode 100644 index fb745fac53ba..000000000000 --- a/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx +++ /dev/null @@ -1,650 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. - -'use client'; - -/** - * @file useTableRowExpansion.tsx - * @input React, StyleX, Icon, Table types - * @output Exports useTableRowExpansion hook + config/state types - * @position Row-expansion plugin; consumed by Table via plugins prop - * @deprecated Superseded by the tree plugin (useTableTreeData + - * useTableTreeState). Kept for back-compat; new tree tables should use the - * tree plugin. See the migration guide on useTableRowExpansion. - * - * SYNC: When modified, update these files to stay in sync: - * - /packages/core/src/Table/index.ts (exports) - */ - -import {useCallback, useMemo, useRef, type ReactNode} from 'react'; -import * as stylex from '@stylexjs/stylex'; -import {spacingVars, colorVars, radiusVars} from '../../../theme/tokens.stylex'; -import {Icon} from '../../../Icon'; -import {rtlStyles} from '../../../utils'; -import {resolveContextActions} from '../../tableContextMenu'; -import {useTranslator} from '../../../i18n'; -import type { - TablePlugin, - TableColumn, - BodyCellRenderProps, - BodyRowRenderProps, - HeaderCellRenderProps, -} from '../../types'; - -// ============================================================================= -// Config -// ============================================================================= - -/** - * Configuration for useTableRowExpansion (inherited-columns mode). - * - * Child rows use the same columns as their parents, with indentation on the - * first content column. The consumer provides a **flat** data array (use - * {@link useTableRowExpansionState} to flatten a tree) and a `getDepth` - * function so the plugin knows each row's nesting level. - */ -export interface UseTableRowExpansionConfig> { - /** Set of currently-expanded row keys. */ - expandedKeys: Set; - /** Called when a row's expansion is toggled. */ - onToggle: (key: string) => void; - /** Derive a stable unique key from a row item. */ - getRowKey: (item: T) => string; - /** Return the children of a row (used to determine expandability). */ - getChildren: (item: T) => T[]; - /** Return the depth of a row in the hierarchy (0 = top-level). */ - getDepth?: (item: T) => number; - /** Optionally control which rows are expandable. @default checks getChildren length */ - getIsItemExpandable?: (item: T) => boolean; - /** - * When true, clicking anywhere on the row toggles expansion (in addition to - * the chevron button). @default false — only the chevron triggers expansion. - */ - hasRowClickExpansion?: boolean; - /** - * State of the expand-all toggle in the header. `true` = all expanded, - * `false` = all collapsed, `'indeterminate'` = mixed. When provided - * (together with `onToggleExpandAll`), the header cell shows a toggle button. - */ - isAllExpanded?: boolean | 'indeterminate'; - /** Called when the expand-all header toggle is clicked. */ - onToggleExpandAll?: (expand: boolean) => void; -} - -/** - * Configuration for {@link useTableRowExpansionState}. - * - * Mirrors the shape of {@link useTableSelectionState}: you own the - * `expandedKeys` set (via `useState`), and the hook derives everything the - * plugin needs — the flattened `data`, per-row depth, expand/collapse - * handlers, and the expand-all toggle state. - */ -export interface UseTableRowExpansionStateConfig< - T extends Record, -> { - /** The full, un-flattened tree. */ - baseData: T[]; - /** Return the children of a row. Leaf rows return an empty array. */ - getChildren: (item: T) => T[]; - /** Derive a stable unique key from a row item. */ - getRowKey: (item: T) => string; - /** - * Should this row be expandable? Rows that return `false` never show a - * chevron and are skipped by expand-all. @default rows with children - */ - getIsItemExpandable?: (item: T) => boolean; - /** Controlled set of currently-expanded row keys. */ - expandedKeys: Set; - /** Setter for the controlled expanded keys. */ - setExpandedKeys: React.Dispatch>>; -} - -export interface UseTableRowExpansionStateResult< - T extends Record, -> { - /** The flattened, currently-visible rows. Pass to `
`. */ - data: T[]; - /** Ready-to-use config for {@link useTableRowExpansion}. */ - expansionConfig: UseTableRowExpansionConfig; -} - -/** - * Manages row-expansion state and derives the config for - * {@link useTableRowExpansion}. - * - * @deprecated Use `useTableTreeState` (with `useTableTreeData`) instead. The - * tree plugin covers the same affordances (expand-all header control, - * whole-row click) with a cycle guard and per-row fine-grained re-render. See - * the migration guide on `useTableRowExpansion` (`astryx component - * useTableRowExpansion --detail full`) for the before/after and config - * mapping. - * - * @example - * ``` - * const [expandedKeys, setExpandedKeys] = useState>(new Set()); - * const {data, expansionConfig} = useTableRowExpansionState({ - * baseData: tree, - * getChildren: item => item.children ?? [], - * getRowKey: item => item.id, - * expandedKeys, - * setExpandedKeys, - * }); - * const expansion = useTableRowExpansion(expansionConfig); - *
; - * ``` - */ -export function useTableRowExpansionState>({ - baseData, - getChildren, - getRowKey, - getIsItemExpandable, - expandedKeys, - setExpandedKeys, -}: UseTableRowExpansionStateConfig): UseTableRowExpansionStateResult { - const isExpandable = useCallback( - (item: T): boolean => - getIsItemExpandable - ? getIsItemExpandable(item) - : getChildren(item).length > 0, - [getIsItemExpandable, getChildren], - ); - - const depthMap = useMemo(() => { - const map = new Map(); - // Ancestor keys on the current walk path — guards against cyclic data. - const path = new Set(); - function walk(items: T[], depth: number) { - for (const item of items) { - const key = getRowKey(item); - if (path.has(key)) { - continue; // cyclic edge — skip - } - map.set(key, depth); - if (expandedKeys.has(key)) { - path.add(key); - walk(getChildren(item), depth + 1); - path.delete(key); - } - } - } - walk(baseData, 0); - return map; - }, [baseData, getChildren, getRowKey, expandedKeys]); - - const data = useMemo(() => { - const result: T[] = []; - // Ancestor keys on the current walk path — guards against cyclic data. - const path = new Set(); - function walk(items: T[]) { - for (const item of items) { - const key = getRowKey(item); - if (path.has(key)) { - continue; // cyclic edge — skip - } - result.push(item); - if (expandedKeys.has(key)) { - path.add(key); - walk(getChildren(item)); - path.delete(key); - } - } - } - walk(baseData); - return result; - }, [baseData, getChildren, getRowKey, expandedKeys]); - - // Every expandable key across the whole tree (drives expand-all). - const allExpandableKeys = useMemo(() => { - const keys: string[] = []; - // Ancestor keys on the current walk path — guards against cyclic data. - const path = new Set(); - function walk(items: T[]) { - for (const item of items) { - const key = getRowKey(item); - if (path.has(key)) { - continue; // cyclic edge — skip - } - if (isExpandable(item)) { - keys.push(key); - path.add(key); - walk(getChildren(item)); - path.delete(key); - } - } - } - walk(baseData); - return keys; - }, [baseData, getChildren, getRowKey, isExpandable]); - - const getDepth = useCallback( - (item: T) => depthMap.get(getRowKey(item)) ?? 0, - [depthMap, getRowKey], - ); - - const onToggle = useCallback( - (key: string) => { - setExpandedKeys(prev => { - const next = new Set(prev); - if (next.has(key)) { - next.delete(key); - } else { - next.add(key); - } - return next; - }); - }, - [setExpandedKeys], - ); - - const isAllExpanded: boolean | 'indeterminate' = useMemo(() => { - if (allExpandableKeys.length === 0) { - return false; - } - const expandedCount = allExpandableKeys.filter(k => - expandedKeys.has(k), - ).length; - if (expandedCount === 0) { - return false; - } - if (expandedCount === allExpandableKeys.length) { - return true; - } - return 'indeterminate'; - }, [allExpandableKeys, expandedKeys]); - - const onToggleExpandAll = useCallback( - (expand: boolean) => { - setExpandedKeys(expand ? new Set(allExpandableKeys) : new Set()); - }, - [setExpandedKeys, allExpandableKeys], - ); - - const expansionConfig = useMemo( - (): UseTableRowExpansionConfig => ({ - expandedKeys, - onToggle, - getRowKey, - getChildren, - getDepth, - getIsItemExpandable, - isAllExpanded, - onToggleExpandAll, - }), - [ - expandedKeys, - onToggle, - getRowKey, - getChildren, - getDepth, - getIsItemExpandable, - isAllExpanded, - onToggleExpandAll, - ], - ); - - return {data, expansionConfig}; -} - -// ============================================================================= -// Styles -// ============================================================================= - -const EXPANSION_COLUMN_WIDTH = {type: 'pixel' as const, value: 40}; - -/** Indentation applied per depth level, in pixels. */ -const INDENT_PER_DEPTH = 24; - -const expansionStyles = stylex.create({ - chevronButton: { - display: 'inline-flex', - alignItems: 'center', - justifyContent: 'center', - width: '24px', - height: '24px', - background: 'transparent', - border: 'none', - borderRadius: radiusVars['--radius-inner'], - cursor: 'pointer', - color: colorVars['--color-icon-secondary'], - transitionProperty: 'transform, color, background-color', - transitionDuration: '150ms', - padding: 0, - flexShrink: '0', - // Match IconButton ghost hover: subtle overlay background - backgroundImage: { - default: null, - ':hover': { - '@media (hover: hover)': `linear-gradient(${colorVars['--color-overlay-hover']}, ${colorVars['--color-overlay-hover']})`, - }, - }, - ':hover': { - color: colorVars['--color-icon-primary'], - }, - }, - chevronExpanded: { - transform: 'rotate(90deg)', - }, - chevronIcon: { - display: 'inline-flex', - transitionProperty: 'transform', - transitionDuration: '150ms', - }, - indentedCell: { - display: 'flex', - alignItems: 'center', - gap: spacingVars['--spacing-1'], - }, - indent: (px: number) => ({ - paddingInlineStart: `${px}px`, - }), - placeholder: { - display: 'inline-block', - width: '24px', - height: '24px', - flexShrink: '0', - }, - clickableRow: { - cursor: 'pointer', - }, -}); - -// ============================================================================= -// Chevron -// ============================================================================= - -function ExpansionChevron({ - isExpanded, - onToggle, - ariaLabel, -}: { - isExpanded: boolean; - onToggle: () => void; - ariaLabel: string; -}) { - return ( - - ); -} - -// ============================================================================= -// Hook -// ============================================================================= - -/** - * Returns a TablePlugin implementing expandable rows with inherited columns. - * - * @deprecated Use `useTableTreeData` (with `useTableTreeState`) instead. The - * tree plugin covers the same affordances (expand-all header control, - * whole-row click) with a cycle guard and per-row fine-grained re-render. See - * the migration guide on this hook's docs (`astryx component - * useTableRowExpansion --detail full`) for the before/after and config - * mapping. - */ -export function useTableRowExpansion>( - config: UseTableRowExpansionConfig, -): TablePlugin { - const t = useTranslator(); - const { - expandedKeys, - onToggle, - getRowKey, - getChildren, - getDepth, - getIsItemExpandable, - hasRowClickExpansion = false, - isAllExpanded, - onToggleExpandAll, - } = config; - - // Track the first non-plugin column key for indentation. - const firstUserColumnKeyRef = useRef(null); - - const expansionColumn = useMemo( - (): TableColumn => ({ - key: '__expansion', - header: '', - width: EXPANSION_COLUMN_WIDTH, - resizable: false, - renderCell: (item: T) => { - // Child rows (depth > 0) show their chevron inline in the first user - // column instead — don't double up here. - const depth = getDepth ? getDepth(item) : 0; - if (depth > 0) { - return null; - } - - const key = getRowKey(item); - const expandable = getIsItemExpandable - ? getIsItemExpandable(item) - : getChildren(item).length > 0; - if (!expandable) { - return null; - } - const isExpanded = expandedKeys.has(key); - return ( - onToggle(key)} - ariaLabel={ - isExpanded - ? t('@astryx.tableRowExpansion.collapseRow') - : t('@astryx.tableRowExpansion.expandRow') - } - /> - ); - }, - }), - [ - expandedKeys, - onToggle, - getRowKey, - getChildren, - getIsItemExpandable, - getDepth, - t, - ], - ); - - return useMemo( - (): TablePlugin => ({ - transformColumns(columns: TableColumn[]) { - // Track the first user column for indentation. - const firstUserCol = columns.find(c => !c.key.startsWith('__')); - firstUserColumnKeyRef.current = firstUserCol?.key ?? null; - - // Wrap the first user column's renderCell to add depth indentation + - // an inline chevron for child rows. This is the inherited-columns - // pattern: child rows use the same columns but indent their first - // content cell. - const wrappedColumns = columns.map(col => { - if (col.key !== firstUserColumnKeyRef.current) { - return col; - } - const originalRenderCell = col.renderCell; - return { - ...col, - renderCell: (item: T): ReactNode => { - const depth = getDepth ? getDepth(item) : 0; - const originalContent = originalRenderCell - ? originalRenderCell(item) - : String( - ((item as Record)[col.key] as - string | number | null | undefined) ?? '', - ); - - if (depth === 0) { - return originalContent; - } - - const indent = (depth - 1) * INDENT_PER_DEPTH; - const key = getRowKey(item); - const isExpanded = expandedKeys.has(key); - const expandable = getIsItemExpandable - ? getIsItemExpandable(item) - : getChildren(item).length > 0; - - const chevron = expandable ? ( - onToggle(key)} - ariaLabel={ - isExpanded - ? t('@astryx.tableRowExpansion.collapseRow') - : t('@astryx.tableRowExpansion.expandRow') - } - /> - ) : ( - - ); - - return ( -
0 && expansionStyles.indent(indent), - )}> - {chevron} - {originalContent} -
- ); - }, - }; - }); - - return [expansionColumn, ...wrappedColumns]; - }, - - transformHeaderCell( - props: HeaderCellRenderProps, - column: TableColumn, - ): HeaderCellRenderProps { - if (column.key !== '__expansion') { - return props; - } - - // Show expand-all toggle when the consumer provides the state + callback. - if (isAllExpanded !== undefined && onToggleExpandAll) { - const allExpanded = isAllExpanded === true; - return { - ...props, - content: ( - - ), - }; - } - - return {...props, content: null}; - }, - - transformBodyCell( - props: BodyCellRenderProps, - column: TableColumn, - item: T, - ): BodyCellRenderProps { - // Contribute "Expand/Collapse row" context-menu action on every cell - // so right-clicking anywhere in the row shows the option. - const expandable = getIsItemExpandable - ? getIsItemExpandable(item) - : getChildren(item).length > 0; - if (!expandable) { - return props; - } - - const key = getRowKey(item); - const isExpanded = expandedKeys.has(key); - return { - ...props, - contextMenuActions: () => [ - ...resolveContextActions(props.contextMenuActions), - { - id: 'row-expansion-toggle', - group: 'row-expansion', - label: isExpanded - ? t('@astryx.tableRowExpansion.collapseRow') - : t('@astryx.tableRowExpansion.expandRow'), - icon: ( - - - - ), - onSelect: () => onToggle(key), - }, - ], - }; - }, - - transformBodyRow(props: BodyRowRenderProps, item: T): BodyRowRenderProps { - if (!hasRowClickExpansion) { - return props; - } - const expandable = getIsItemExpandable - ? getIsItemExpandable(item) - : getChildren(item).length > 0; - if (!expandable) { - return props; - } - const key = getRowKey(item); - return { - ...props, - htmlProps: { - ...props.htmlProps, - onClick: () => onToggle(key), - }, - xstyle: [...props.xstyle, expansionStyles.clickableRow], - }; - }, - }), - [ - expandedKeys, - getRowKey, - onToggle, - getChildren, - getDepth, - getIsItemExpandable, - hasRowClickExpansion, - isAllExpanded, - onToggleExpandAll, - expansionColumn, - t, - ], - ); -} diff --git a/packages/core/src/Table/types.ts b/packages/core/src/Table/types.ts index 35217769087e..fef4dc884a8f 100644 --- a/packages/core/src/Table/types.ts +++ b/packages/core/src/Table/types.ts @@ -575,15 +575,6 @@ export interface BaseTableProps< /** Children mode — render `
`/`
` directly instead of data-driven */ children?: ReactNode; - /** - * Additional HTML attributes for the `` element. - * - * @deprecated Pass `className`, `style`, `xstyle`, and other HTML - * attributes directly on the component instead — they now reach the root - * `
` and win over `tableProps` on conflicts. Migrate with - * `npx astryx upgrade --codemod migrate-table-tableprops-to-direct-props`. - */ - tableProps?: HTMLAttributes; /** * Optional wrapper rendered around the `
` element, inside the * plugin `transformTableContext` layer. Used by `Table` to add a diff --git a/packages/core/src/Table/useTableGroupedRows.doc.mjs b/packages/core/src/Table/useTableGroupedRows.doc.mjs index 0e665402e3c4..007d6f250cde 100644 --- a/packages/core/src/Table/useTableGroupedRows.doc.mjs +++ b/packages/core/src/Table/useTableGroupedRows.doc.mjs @@ -7,7 +7,7 @@ export const docs = { subComponentOf: 'Table', displayName: 'useTableGroupedRows', description: - 'Hook that groups a flat data array into collapsible section rows. Each distinct groupBy value becomes a full-width section-header row with a chevron toggle, the group label, and a member count; collapsing hides that group\'s data rows while keeping the header visible. Mirrors useTableRowExpansionState: the consumer owns the collapsedGroups set and the hook returns {data, plugin, idKey}: pass all three to Table (data, plugins, and idKey respectively).', + 'Hook that groups a flat data array into collapsible section rows. Each distinct groupBy value becomes a full-width section-header row with a chevron toggle, the group label, and a member count; collapsing hides that group\'s data rows while keeping the header visible. The consumer owns the collapsedGroups set and the hook returns {data, plugin, idKey}: pass all three to Table (data, plugins, and idKey respectively).', props: [ { name: 'data', diff --git a/packages/core/src/Table/useTableRowExpansion.doc.mjs b/packages/core/src/Table/useTableRowExpansion.doc.mjs deleted file mode 100644 index ffd52033ac1a..000000000000 --- a/packages/core/src/Table/useTableRowExpansion.doc.mjs +++ /dev/null @@ -1,133 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. - -/** @type {import('@astryxdesign/cli/authoring').ComponentDoc} */ - -export const docs = { - name: 'useTableRowExpansion', - subComponentOf: 'Table', - displayName: 'useTableRowExpansion', - description: - 'Deprecated: use useTableTreeData + useTableTreeState instead. Hook that returns a TablePlugin implementing expandable rows with inherited columns. Child rows use the same columns as their parents, indented by depth. Clicking the chevron (or right-click context menu) toggles expansion. Pair with useTableRowExpansionState, which flattens the tree and derives this config (expand/collapse handlers + expand-all state) from a single expandedKeys set. Converging with useTableTreeData: new tree tables should prefer useTableTreeData + useTableTreeState, which cover the same affordances with a cycle guard and fine-grained re-render. See the migration example below.', - props: [ - { - name: 'expandedKeys', - type: 'Set', - description: 'Set of currently-expanded row keys.', - required: true, - }, - { - name: 'onToggle', - type: '(key: string) => void', - description: 'Called when a row expansion is toggled.', - required: true, - }, - { - name: 'getRowKey', - type: '(item: T) => string', - description: 'Derive a stable unique key from a row item.', - required: true, - }, - { - name: 'getChildren', - type: '(item: T) => T[]', - description: 'Return the children of a row (determines expandability).', - required: true, - }, - { - name: 'getDepth', - type: '(item: T) => number', - description: 'Return the depth of a row in the hierarchy (0 = top-level). Used for indentation.', - }, - { - name: 'getIsItemExpandable', - type: '(item: T) => boolean', - description: 'Control which rows are expandable. Defaults to checking getChildren length.', - }, - { - name: 'hasRowClickExpansion', - type: 'boolean', - description: 'When true, clicking anywhere on the row toggles expansion.', - default: 'false', - }, - { - name: 'isAllExpanded', - type: "boolean | 'indeterminate'", - description: 'State of the expand-all toggle in the header. Enables the header toggle button.', - }, - { - name: 'onToggleExpandAll', - type: '(expand: boolean) => void', - description: 'Callback when the expand-all header toggle is clicked.', - }, - ], - examples: [ - { - label: 'Migrating to useTableTreeData + useTableTreeState', - code: `// useTableRowExpansion and useTableTreeData are converging onto one tree -// plugin. useTableTreeData is the destination: it adds a cycle guard, -// per-row fine-grained re-render, and imperative row ARIA, and now covers -// the same affordances (expand-all header control, whole-row click). - -// BEFORE: useTableRowExpansion + useTableRowExpansionState -const [expandedKeys, setExpandedKeys] = useState(new Set(['root'])); -const {data, expansionConfig} = useTableRowExpansionState({ - baseData: tree, - getChildren: item => item.children ?? [], - getRowKey: item => item.id, - expandedKeys, - setExpandedKeys, -}); -const expansion = useTableRowExpansion(expansionConfig); -
; - -// AFTER: useTableTreeState + useTableTreeData -const {visibleData, treeConfig} = useTableTreeState({ - data: tree, // nested data, not a flat array - idKey: 'id', // or a function: idKey={item => item.id} - childrenKey: 'children', // replaces getChildren (default 'children') - defaultExpandedIds: ['root'], // uncontrolled; or expandedIds + onExpandedIdsChange -}); -const tree = useTableTreeData({ - ...treeConfig, - hasExpandAllControl: true, // was isAllExpanded + onToggleExpandAll - hasRowClickExpansion: true, // same prop name -}); -
;`, - }, - { - label: 'Config mapping', - code: `// useTableRowExpansion(State) -> useTableTreeState / useTableTreeData - -// baseData: T[] (nested) -> data: T[] (useTableTreeState) -// getChildren: item => item.children -> childrenKey: 'children' (property name) -// getRowKey: item => item.id -> idKey: 'id' | (item => item.id) -// getIsItemExpandable -> isItemExpandable (same shape) -// expandedKeys + setExpandedKeys -> defaultExpandedIds (uncontrolled), or -// expandedIds + onExpandedIdsChange (controlled) -// getDepth -> removed; depth derives from nesting -// isAllExpanded + onToggleExpandAll -> hasExpandAllControl (state is computed) -// hasRowClickExpansion -> hasRowClickExpansion (unchanged) - -// Rendering: useTableRowExpansion prepends a dedicated expander column; -// useTableTreeData decorates the tree column in place (configurable via -// treeColumnKey). Keyboard and AT users toggle via the chevron in both.`, - }, - ], -}; - -/** @type {import('@astryxdesign/cli/authoring').ComponentTranslationDoc} */ -export const docsDense = { - description: - 'Deprecated: use useTableTreeData + useTableTreeState instead. Returns a TablePlugin for expandable rows w/ inherited columns. Child rows reuse parent columns, indented by depth. Chevron click (or right-click menu) toggles expansion. Pair w/ useTableRowExpansionState, which flattens the tree + derives this config from one expandedKeys set.', - propDescriptions: { - expandedKeys: 'Set of currently-expanded row keys.', - onToggle: 'Called when a row expansion is toggled.', - getRowKey: 'Derive a stable unique key from a row item.', - getChildren: 'Return children of a row; determines expandability.', - getDepth: 'Return depth of a row (0 = top-level). Used for indentation.', - getIsItemExpandable: 'Control which rows are expandable. Defaults to checking getChildren length.', - hasRowClickExpansion: 'If true, clicking anywhere on the row toggles expansion. Default false.', - isAllExpanded: 'State of the expand-all header toggle. Enables the header toggle button.', - onToggleExpandAll: 'Callback when the expand-all header toggle is clicked.', - }, -}; diff --git a/packages/core/src/TopNav/TopNav.test.tsx b/packages/core/src/TopNav/TopNav.test.tsx index 75fb34a07611..034c2c064b0f 100644 --- a/packages/core/src/TopNav/TopNav.test.tsx +++ b/packages/core/src/TopNav/TopNav.test.tsx @@ -281,8 +281,8 @@ describe('TopNavHeading', () => { }); }); - it('renders as anchor when href is provided', () => { - render(); + it('renders as anchor when headingHref is provided', () => { + render(); const link = screen.getByRole('link'); expect(link).toHaveAttribute('href', '/'); }); diff --git a/packages/core/src/TopNav/TopNavHeading.doc.mjs b/packages/core/src/TopNav/TopNavHeading.doc.mjs index dcdf89684471..f349878a03a9 100644 --- a/packages/core/src/TopNav/TopNavHeading.doc.mjs +++ b/packages/core/src/TopNav/TopNavHeading.doc.mjs @@ -33,11 +33,6 @@ export const docs = { type: 'string', description: 'Link for the heading text (e.g. product home). When no menu is present and this is the only href, the whole heading becomes one clickable link.', }, - { - name: 'href', - type: 'string', - description: 'Deprecated: use headingHref instead. URL to navigate to when clicked.', - }, { name: 'superheading', type: 'string', @@ -122,11 +117,6 @@ export const docsZh = { type: 'string', description: '标题文本的链接(如产品首页)。当没有菜单且这是唯一的 href 时,整个标题变为一个可点击链接。', }, - { - name: 'href', - type: 'string', - description: '已弃用,请使用 headingHref。点击时导航到的 URL。', - }, { name: 'superheading', type: 'string', @@ -179,7 +169,6 @@ export const docsDense = { logo: 'Logo before heading text. Image, NavIcon, or ReactNode.', heading: 'Product/app name.', headingHref: 'Link for heading (product home). Only href + no menu → whole heading is link.', - href: 'Deprecated: use headingHref.', superheading: 'Text above heading (suite name). Smaller secondary style.', superheadingHref: 'Link for superheading. Independent inline link when menu present.', subheading: 'Text below heading (account context). Smaller secondary style.', diff --git a/packages/core/src/TopNav/TopNavHeading.tsx b/packages/core/src/TopNav/TopNavHeading.tsx index d97e88c24c4a..e00328315c36 100644 --- a/packages/core/src/TopNav/TopNavHeading.tsx +++ b/packages/core/src/TopNav/TopNavHeading.tsx @@ -227,14 +227,8 @@ export interface TopNavHeadingProps extends BaseProps { heading?: string; /** * Link for the heading (e.g., product home). - * Alias: `href` (for backward compatibility). */ headingHref?: string; - /** - * @deprecated Use `headingHref` instead. - * URL to navigate to when the heading is clicked. - */ - href?: string; /** * Text above the heading (e.g., suite name). */ @@ -304,8 +298,7 @@ export function TopNavHeading({ logo, logoLabel, heading, - headingHref: headingHrefProp, - href, + headingHref, superheading, superheadingHref, subheading, @@ -322,8 +315,6 @@ export function TopNavHeading({ const t = useTranslator(); const chevronDownIcon = useIcon('chevronDown'); const LinkComponent = useLinkComponent(as); - // Support both headingHref and legacy href - const headingHref = headingHrefProp ?? href; // When the logo is wrapped in a link it needs its own accessible name (the // logo image itself is decorative). Prefer an explicit logoLabel, fall back // to the heading text. axe: link-name. diff --git a/packages/core/src/TreeList/TreeList.test.tsx b/packages/core/src/TreeList/TreeList.test.tsx index 991e903c866f..f1317dcc390d 100644 --- a/packages/core/src/TreeList/TreeList.test.tsx +++ b/packages/core/src/TreeList/TreeList.test.tsx @@ -15,8 +15,12 @@ import userEvent from '@testing-library/user-event'; import {TreeList} from './TreeList'; import type {TreeListItemData} from './TreeListTypes'; import {defineTheme} from '../theme/defineTheme'; -import {generateThemeCSSFlat} from '../theme/generateThemeRules'; +import {generateThemeCSS} from '../theme/generateThemeRules'; +function generateThemeTestCSS(theme: Parameters[0]) { + const {prose, component} = generateThemeCSS(theme); + return [prose, component].filter(Boolean).join('\n\n'); +} const simpleItems: TreeListItemData[] = [ {id: 'a', label: 'Item A'}, {id: 'b', label: 'Item B'}, @@ -485,7 +489,7 @@ describe('TreeList', () => { }, }, }); - const css = generateThemeCSSFlat(theme); + const css = generateThemeTestCSS(theme); expect(css).toContain('.astryx-tree-list-guide {'); expect(css).toContain('background-color: var(--color-accent)'); }); @@ -501,7 +505,7 @@ describe('TreeList', () => { }, }, }); - const css = generateThemeCSSFlat(theme); + const css = generateThemeTestCSS(theme); expect(css).toContain('.astryx-tree-list-guide {'); expect(css).toContain('display: none'); }); @@ -525,7 +529,7 @@ describe('TreeList', () => { }, }, }); - const css = generateThemeCSSFlat(theme); + const css = generateThemeTestCSS(theme); expect(css).toContain('.astryx-tree-list {'); expect(css).toContain('--tree-list-indent: var(--spacing-5)'); }); @@ -609,7 +613,7 @@ describe('TreeList', () => { }, }, }); - const css = generateThemeCSSFlat(theme); + const css = generateThemeTestCSS(theme); expect(css).toContain('.astryx-tree-list-chevron {'); expect(css).toContain('color: var(--color-accent)'); expect(css).toContain('.astryx-tree-list-chevron.expanded'); @@ -667,7 +671,7 @@ describe('TreeList', () => { }, }, }); - const css = generateThemeCSSFlat(theme); + const css = generateThemeTestCSS(theme); expect(css).toContain('.astryx-tree-list-item-label {'); expect(css).toContain('color: var(--color-text-primary)'); expect(css).toContain('.astryx-tree-list-item-label.selected'); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8e2b78c1ffe5..40b9962883b7 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -173,24 +173,3 @@ export * from './theme'; // Internationalization export * from './i18n'; - -// Doc types — for external library authors writing .doc.mjs files. -// @deprecated Import these from `@astryxdesign/cli/authoring` instead. These -// re-exports are kept for one release cycle and will be removed; `astryx upgrade` -// repoints them automatically. -export type { - ComponentDoc, - SingleComponentDoc, - MultiComponentDoc, - ComponentPropDoc, - ComponentEntry, - ComponentThemingTarget, - ComponentThemingVar, - ComponentThemingDerivedVar, - ComponentTranslationDoc, - ComponentGroupDoc, - ReferenceDoc, - ReferenceSection, - ReferenceContentBlock, - ReferenceTokenPreviewType, -} from '@astryxdesign/cli/authoring'; diff --git a/packages/core/src/theme/defineTheme.test.ts b/packages/core/src/theme/defineTheme.test.ts index 8004818af6c7..75540401ea80 100644 --- a/packages/core/src/theme/defineTheme.test.ts +++ b/packages/core/src/theme/defineTheme.test.ts @@ -2,13 +2,12 @@ import {describe, it, expect, vi} from 'vitest'; import type {IconRegistry} from '../Icon/globalIconRegistry'; -import { - defineTheme, - generateThemeCSS, - generateThemeCSSFlat, - isDefinedTheme, -} from './defineTheme'; +import {defineTheme, generateThemeCSS, isDefinedTheme} from './defineTheme'; +function generateThemeTestCSS(theme: Parameters[0]) { + const {prose, component} = generateThemeCSS(theme); + return [prose, component].filter(Boolean).join('\n\n'); +} describe('defineTheme', () => { it('creates a theme with name', () => { const theme = defineTheme({name: 'test'}); @@ -100,7 +99,7 @@ describe('generateThemeCSS', () => { '--radius-container': '16px', }, }); - const css = generateThemeCSSFlat(theme); + const css = generateThemeTestCSS(theme); expect(css).toContain('@scope'); expect(css).toContain('--color-accent: light-dark(#0077B6, #48CAE4)'); expect(css).toContain('--radius-container: 16px'); @@ -110,7 +109,7 @@ describe('generateThemeCSS', () => { it('includes prose rules even with no overrides', () => { const theme = defineTheme({name: 'empty'}); - const css = generateThemeCSSFlat(theme); + const css = generateThemeTestCSS(theme); expect(css).toContain('@scope'); expect(css).toContain(':where(h1, h2, h3, h4, h5, h6)'); expect(css).toContain('font-family: var(--font-family-heading)'); @@ -192,7 +191,7 @@ describe('generateThemeCSS with components', () => { }, }, }); - const css = generateThemeCSSFlat(theme); + const css = generateThemeTestCSS(theme); expect(css).toContain('.astryx-card {'); expect(css).toContain('border-width: 2px'); expect(css).toContain('border-color: var(--color-accent)'); @@ -211,7 +210,7 @@ describe('generateThemeCSS with components', () => { }, }, }); - const css = generateThemeCSSFlat(theme); + const css = generateThemeTestCSS(theme); expect(css).toContain('.astryx-button.secondary'); expect(css).toContain('background-color: rgba(0,0,0,0.06)'); }); @@ -227,7 +226,7 @@ describe('generateThemeCSS with components', () => { }, }, }); - const css = generateThemeCSSFlat(theme); + const css = generateThemeTestCSS(theme); expect(css).toContain('.astryx-button.destructive.sm'); expect(css).toContain('padding: 2px 6px'); }); @@ -241,7 +240,7 @@ describe('generateThemeCSS with components', () => { }, }, }); - const css = generateThemeCSSFlat(theme); + const css = generateThemeTestCSS(theme); expect(css).toContain('font-family: "Playfair Display", serif'); expect(css).not.toContain('fontFamily'); }); @@ -254,7 +253,7 @@ describe('generateThemeCSS with components', () => { card: {base: {borderWidth: '1px'}}, }, }); - const css = generateThemeCSSFlat(theme); + const css = generateThemeTestCSS(theme); expect(css).toContain('@scope'); expect(css).toContain('--radius-container: 20px'); expect(css).toContain('.astryx-card {'); @@ -488,7 +487,7 @@ describe('custom status via components', () => { }, }, }); - const css = generateThemeCSSFlat(theme); + const css = generateThemeTestCSS(theme); // parseStyleKey('status:neutral') → '.neutral', so CSS should have .astryx-banner.neutral expect(css).toContain('.astryx-banner.neutral'); expect(css).toContain('background-color: var(--color-background-muted)'); @@ -521,7 +520,7 @@ describe('custom status via components', () => { }, }, }); - const css = generateThemeCSSFlat(theme); + const css = generateThemeTestCSS(theme); expect(css).toContain('.astryx-button.primary-muted'); expect(css).toContain('background-color: #ECF5FF'); }); @@ -733,7 +732,7 @@ describe('pseudo-class overrides in components', () => { }, }, }); - const css = generateThemeCSSFlat(theme); + const css = generateThemeTestCSS(theme); // Base rule expect(css).toContain('.astryx-radio {'); expect(css).toContain('border-color: #8F9296'); @@ -761,7 +760,7 @@ describe('pseudo-class overrides in components', () => { }, }, }); - const css = generateThemeCSSFlat(theme); + const css = generateThemeTestCSS(theme); expect(css).toContain('.astryx-button.primary-muted {'); expect(css).toContain('background-color: #ECF5FF'); expect(css).toContain('.astryx-button.primary-muted:hover {'); @@ -783,7 +782,7 @@ describe('pseudo-class overrides in components', () => { }, }, }); - const css = generateThemeCSSFlat(theme); + const css = generateThemeTestCSS(theme); // Should NOT emit an empty base rule expect(css).not.toMatch(/\.astryx-switch\s*\{\s*\}/); // Should emit the pseudo rule @@ -802,7 +801,7 @@ describe('pseudo-class overrides in components', () => { }, }, }); - const css = generateThemeCSSFlat(theme); + const css = generateThemeTestCSS(theme); expect(css).toContain('.astryx-card {'); expect(css).toContain('border-width: 2px'); expect(css).toContain('border-color: var(--color-accent)'); @@ -821,7 +820,7 @@ describe('container padding mapping', () => { }, }, }); - const css = generateThemeCSSFlat(theme); + const css = generateThemeTestCSS(theme); // Should NOT emit raw padding property (only the scoped token) expect(css).not.toMatch(/[^-]padding: 20px/); // Should emit component-scoped shorthand token @@ -840,7 +839,7 @@ describe('container padding mapping', () => { }, }, }); - const css = generateThemeCSSFlat(theme); + const css = generateThemeTestCSS(theme); expect(css).toContain('--astryx-card-padding-inline: 20px'); expect(css).toContain('--astryx-card-padding-block-start: 16px'); expect(css).toContain('--astryx-card-padding-block-end: 16px'); @@ -857,7 +856,7 @@ describe('container padding mapping', () => { }, }, }); - const css = generateThemeCSSFlat(theme); + const css = generateThemeTestCSS(theme); expect(css).toContain('--astryx-card-padding-inline: 16px'); expect(css).toContain('--astryx-card-padding-block-start: 24px'); expect(css).toContain('--astryx-card-padding-block-end: 24px'); @@ -875,7 +874,7 @@ describe('container padding mapping', () => { }, }, }); - const css = generateThemeCSSFlat(theme); + const css = generateThemeTestCSS(theme); // Section — uniform → shorthand expect(css).toContain('--astryx-section-padding: 12px'); // Dialog — asymmetric → directional @@ -892,7 +891,7 @@ describe('container padding mapping', () => { }, }, }); - const css = generateThemeCSSFlat(theme); + const css = generateThemeTestCSS(theme); // Button is not a container — padding passes through as-is expect(css).toContain('padding: 8px 16px'); expect(css).not.toContain('--astryx-button-padding'); @@ -911,7 +910,7 @@ describe('container padding mapping', () => { }, }, }); - const css = generateThemeCSSFlat(theme); + const css = generateThemeTestCSS(theme); // Non-padding props pass through expect(css).toContain('--_card-radius: 16px'); expect(css).toContain('background-color: white'); @@ -929,7 +928,7 @@ describe('container padding mapping', () => { }, }, }); - const css = generateThemeCSSFlat(theme); + const css = generateThemeTestCSS(theme); expect(css).toContain('--astryx-card-padding-block-start: 16px'); expect(css).toContain('--astryx-card-padding-block-end: 12px'); expect(css).toContain('--astryx-card-padding-inline: 20px'); @@ -944,7 +943,7 @@ describe('container padding mapping', () => { }, }, }); - const css = generateThemeCSSFlat(theme); + const css = generateThemeTestCSS(theme); expect(css).toContain('--astryx-card-padding-block-start: 16px'); expect(css).toContain('--astryx-card-padding-block-end: 24px'); }); @@ -958,7 +957,7 @@ describe('container padding mapping', () => { }, }, }); - const css = generateThemeCSSFlat(theme); + const css = generateThemeTestCSS(theme); expect(css).toContain('--astryx-card-padding-inline-start: 12px'); expect(css).toContain('--astryx-card-padding-inline-end: 20px'); }); @@ -972,7 +971,7 @@ describe('container padding mapping', () => { }, }, }); - const css = generateThemeCSSFlat(theme); + const css = generateThemeTestCSS(theme); expect(css).toContain('--astryx-card-padding-block-start: 32px'); expect(css).not.toContain('--astryx-card-padding-block-end'); expect(css).not.toContain('--astryx-card-padding-inline'); @@ -987,7 +986,7 @@ describe('container padding mapping', () => { }, }, }); - const css = generateThemeCSSFlat(theme); + const css = generateThemeTestCSS(theme); expect(css).toContain('--astryx-card-padding-inline-start: 8px'); expect(css).toContain('--astryx-card-padding-inline-end: 24px'); }); diff --git a/packages/core/src/theme/defineTheme.ts b/packages/core/src/theme/defineTheme.ts index f0c713c32977..08c19f477fff 100644 --- a/packages/core/src/theme/defineTheme.ts +++ b/packages/core/src/theme/defineTheme.ts @@ -45,7 +45,6 @@ import { shadowDefaults, durationDefaults, easeDefaults, - transitionDefaults, typographyDefaults, textSizeDefaults, fontWeightDefaults, @@ -78,7 +77,6 @@ export type CoreTokenName = | keyof typeof shadowDefaults | keyof typeof durationDefaults | keyof typeof easeDefaults - | keyof typeof transitionDefaults | keyof typeof typographyDefaults | keyof typeof textSizeDefaults | keyof typeof fontWeightDefaults @@ -367,7 +365,6 @@ export const tokenDefaults: Record = { ...shadowDefaults, ...durationDefaults, ...easeDefaults, - ...transitionDefaults, ...typographyDefaults, ...textSizeDefaults, ...fontWeightDefaults, @@ -648,7 +645,6 @@ export { generateThemeRulesSplit, generateOnMediaCSS, generateThemeCSS, - generateThemeCSSFlat, type ThemeRulesSplit, type ThemeCSSOutput, } from './generateThemeRules'; diff --git a/packages/core/src/theme/generateThemeRules.ts b/packages/core/src/theme/generateThemeRules.ts index 0fef62eed258..8bbeaea93501 100644 --- a/packages/core/src/theme/generateThemeRules.ts +++ b/packages/core/src/theme/generateThemeRules.ts @@ -708,18 +708,3 @@ export function generateThemeCSS(theme: DefinedTheme): ThemeCSSOutput { return {prose: proseCss, component: componentCss}; } - -/** - * Generate the full CSS string for a theme as a single string. - * @deprecated Use generateThemeCSS() which returns { prose, component } for proper layering. - * This flat version is kept for backwards compatibility with tests and simple cases. - */ -export function generateThemeCSSFlat(theme: DefinedTheme): string { - const rules = generateThemeRules(theme); - if (rules.length === 0) { - return ''; - } - const scopeSelector = themeScopeStart(theme.name); - const inner = rules.join('\n\n'); - return `@scope (${scopeSelector}) to (${THEME_SCOPE_TO}) {\n${inner}\n}`; -} diff --git a/packages/core/src/theme/index.ts b/packages/core/src/theme/index.ts index e2230431f0bf..dda4fce9f37a 100644 --- a/packages/core/src/theme/index.ts +++ b/packages/core/src/theme/index.ts @@ -21,7 +21,6 @@ export type {MediaThemeProps} from './MediaTheme'; export { defineTheme, generateThemeCSS, - generateThemeCSSFlat, generateOnMediaCSS, generateThemeRules, generateThemeRulesSplit, @@ -95,7 +94,6 @@ export { shadowDefaults, durationDefaults, easeDefaults, - transitionDefaults, typographyDefaults, textSizeDefaults, fontWeightDefaults, @@ -108,7 +106,6 @@ export { shadowVars, durationVars, easeVars, - transitionVars, typographyVars, textSizeVars, fontWeightVars, @@ -125,7 +122,6 @@ export type { ShadowVarName, DurationVarName, EaseVarName, - TransitionVarName, TypographyVarName, TextSizeVarName, FontWeightVarName, diff --git a/packages/core/src/theme/tokens.stylex.ts b/packages/core/src/theme/tokens.stylex.ts index 53c71202b25f..9db606ac29d3 100644 --- a/packages/core/src/theme/tokens.stylex.ts +++ b/packages/core/src/theme/tokens.stylex.ts @@ -272,23 +272,6 @@ export const easeDefaults = { export const easeVars = stylex.defineVars(easeDefaults); export type EaseVarName = keyof typeof easeDefaults; - -// ============================================================================= -// Motion Tokens — Deprecated (transition shorthand) -// ============================================================================= - -/** @deprecated Use durationVars + easeVars instead */ -export const transitionDefaults = { - '--transition-fast': '0.15s ease', - '--transition-normal': '0.2s ease', -} as const; - -/** @deprecated Use durationVars + easeVars instead */ -export const transitionRaw = transitionDefaults; - -/** @deprecated Use durationVars + easeVars instead */ -export const transitionVars = stylex.defineVars(transitionDefaults); - // ============================================================================= // Typography Tokens - Font Families // ============================================================================= @@ -347,8 +330,6 @@ export type SpacingVarName = keyof typeof spacingDefaults; export type SizeVarName = keyof typeof sizeDefaults; export type RadiusVarName = keyof typeof radiusDefaults; export type ShadowVarName = keyof typeof shadowDefaults; -/** @deprecated Use DurationVarName | EaseVarName instead */ -export type TransitionVarName = keyof typeof transitionDefaults; export type TypographyVarName = keyof typeof typographyDefaults; export type TextSizeVarName = keyof typeof textSizeDefaults; export type FontWeightVarName = keyof typeof fontWeightDefaults; diff --git a/packages/lab/src/CodeBlock/index.ts b/packages/lab/src/CodeBlock/index.ts deleted file mode 100644 index befa9a955286..000000000000 --- a/packages/lab/src/CodeBlock/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. - -/** - * @file CodeBlock component barrel export - * @deprecated Import from '@astryxdesign/core/CodeBlock' instead. - * This re-export exists for backwards compatibility. - */ - -export {CodeBlock} from '@astryxdesign/core/CodeBlock'; -export type {CodeBlockProps} from '@astryxdesign/core/CodeBlock'; diff --git a/packages/lab/src/index.ts b/packages/lab/src/index.ts index afe37523c132..81905fa95678 100644 --- a/packages/lab/src/index.ts +++ b/packages/lab/src/index.ts @@ -13,7 +13,6 @@ */ // Code components — syntax highlighting domain -export {CodeBlock, type CodeBlockProps} from './CodeBlock'; export {CodeEditor, type CodeEditorProps} from './CodeEditor'; export { tokenize, From c4fabddfa6136070a9e2b5039040a5e8f35cc527 Mon Sep 17 00:00:00 2001 From: cixzhang Date: Sun, 2 Aug 2026 22:37:25 +0000 Subject: [PATCH 2/5] chore: sync generated theme templates --- .../templates/themes/neutral/neutralTheme.ts | 36 ++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/packages/cli/assets/templates/themes/neutral/neutralTheme.ts b/packages/cli/assets/templates/themes/neutral/neutralTheme.ts index e63208148881..ace0fd91ecf7 100644 --- a/packages/cli/assets/templates/themes/neutral/neutralTheme.ts +++ b/packages/cli/assets/templates/themes/neutral/neutralTheme.ts @@ -144,7 +144,10 @@ export const neutralTheme = defineTheme({ // Text '--color-text-primary': ['#171717', '#fafafa'], - '--color-text-secondary': ['#737373', '#a3a3a3'], + // Light secondary is neutral-600 (#525252), not 500 (#737373): 500 only + // reaches 4.19:1 on the T95 body (#f1f1f1), just under WCAG AA 4.5:1. + // 600 clears it (6.9:1 on body, 7.8:1 on card). Dark stays neutral-400. + '--color-text-secondary': ['#525252', '#a3a3a3'], '--color-text-disabled': ['#a3a3a3', '#525252'], '--color-text-accent': ['#262626', '#ebebeb'], '--color-on-dark': '#ffffff', @@ -482,6 +485,37 @@ export const neutralTheme = defineTheme({ }, }, + // ========================================================================= + // StatusDot — fill uses the SAME vivid stops as the filled semantic Badge + // (and ProgressBar), so a dot and its badge read as one status language. + // + // The default component maps each variant to a raw semantic token + // (--color-success / --color-error / --color-warning / --color-icon- + // secondary), which in light mode are the dark T30/T40 stops meant to + // sit as TEXT on a pastel surface — as a solid dot they read muddy + // (dark green / maroon / brown). Redirect them to the badge fills. + // + // success → badge success bg (green T45 / dark-ramp T60) + // warning → badge warning bg (yellow T85, same hex both modes) + // error → badge error bg (red T55 / dark-ramp T60) + // accent → badge info bg (blue T50 / dark-ramp T60) — the + // StatusDot "accent" is the info/attention color, so it + // pairs with the info badge rather than --color-accent + // (near-black #262626, the darkest offender). + // + // `neutral` is intentionally NOT overridden: the neutral badge bg is a + // near-invisible light gray (--color-background-gray #e5e5e5 / 10% white + // wash), fine as a large pill but unreadable as an 8px dot. It keeps the + // component default's visible mid-gray (--color-icon-secondary), which is + // not among the "too dark" cases. + // ========================================================================= + statusdot: { + 'variant:success': {backgroundColor: 'light-dark(#198100, #64af4c)'}, + 'variant:warning': {backgroundColor: '#ffce2f'}, + 'variant:error': {backgroundColor: 'light-dark(#e33f4a, #ff705d)'}, + 'variant:accent': {backgroundColor: 'light-dark(#0074e2, #6d9cfe)'}, + }, + // ========================================================================= // Banner — sits on a hue-tinted surface with colored text/icon: // Light: pastel T90 bg (pulled from --color-{X}-muted / --color-background-blue) From bb480b1c3d3a729fc81dba69aa0b45376e7ef51e Mon Sep 17 00:00:00 2001 From: cixzhang Date: Mon, 3 Aug 2026 01:47:08 +0000 Subject: [PATCH 3/5] chore: update top nav templates --- .../blocks/components/TopNav/TopNavCenteredNavigation.tsx | 2 +- .../blocks/components/TopNav/TopNavEnterpriseDashboard.tsx | 6 +++--- .../templates/blocks/components/TopNav/TopNavHoverMenu.tsx | 2 +- .../templates/blocks/components/TopNav/TopNavMegaMenu.tsx | 2 +- .../blocks/components/TopNav/TopNavMultipleDropdowns.tsx | 2 +- .../templates/blocks/components/TopNav/TopNavWithLogo.tsx | 2 +- .../components/TopNavHeading/TopNavHeadingShowcase.tsx | 6 +++++- packages/cli/assets/templates/pages/shell-top-nav/page.tsx | 4 ++-- 8 files changed, 15 insertions(+), 11 deletions(-) diff --git a/packages/cli/assets/templates/blocks/components/TopNav/TopNavCenteredNavigation.tsx b/packages/cli/assets/templates/blocks/components/TopNav/TopNavCenteredNavigation.tsx index 9e2ea2d3e44b..da1e2a92ccd3 100644 --- a/packages/cli/assets/templates/blocks/components/TopNav/TopNavCenteredNavigation.tsx +++ b/packages/cli/assets/templates/blocks/components/TopNav/TopNavCenteredNavigation.tsx @@ -16,7 +16,7 @@ export default function TopNavCenteredNavigation() { } />} - href="#" + headingHref="#" /> } centerContent={ diff --git a/packages/cli/assets/templates/blocks/components/TopNav/TopNavEnterpriseDashboard.tsx b/packages/cli/assets/templates/blocks/components/TopNav/TopNavEnterpriseDashboard.tsx index 99d1ce45289f..24c543749a30 100644 --- a/packages/cli/assets/templates/blocks/components/TopNav/TopNavEnterpriseDashboard.tsx +++ b/packages/cli/assets/templates/blocks/components/TopNav/TopNavEnterpriseDashboard.tsx @@ -21,20 +21,20 @@ export default function TopNavEnterpriseDashboard() { } />} - href="#" + headingHref="#" /> } startContent={ <> } /> } /> diff --git a/packages/cli/assets/templates/blocks/components/TopNav/TopNavHoverMenu.tsx b/packages/cli/assets/templates/blocks/components/TopNav/TopNavHoverMenu.tsx index e8e2a3759819..05950eb43d9d 100644 --- a/packages/cli/assets/templates/blocks/components/TopNav/TopNavHoverMenu.tsx +++ b/packages/cli/assets/templates/blocks/components/TopNav/TopNavHoverMenu.tsx @@ -28,7 +28,7 @@ export default function TopNavHoverMenu() { } />} - href="#" + headingHref="#" /> } startContent={ diff --git a/packages/cli/assets/templates/blocks/components/TopNav/TopNavMegaMenu.tsx b/packages/cli/assets/templates/blocks/components/TopNav/TopNavMegaMenu.tsx index bbfe4b1cdce0..933be4f5f67e 100644 --- a/packages/cli/assets/templates/blocks/components/TopNav/TopNavMegaMenu.tsx +++ b/packages/cli/assets/templates/blocks/components/TopNav/TopNavMegaMenu.tsx @@ -30,7 +30,7 @@ export default function TopNavMegaMenuBlock() { } />} - href="#" + headingHref="#" /> } startContent={ diff --git a/packages/cli/assets/templates/blocks/components/TopNav/TopNavMultipleDropdowns.tsx b/packages/cli/assets/templates/blocks/components/TopNav/TopNavMultipleDropdowns.tsx index 910634abfb54..3bd759738db0 100644 --- a/packages/cli/assets/templates/blocks/components/TopNav/TopNavMultipleDropdowns.tsx +++ b/packages/cli/assets/templates/blocks/components/TopNav/TopNavMultipleDropdowns.tsx @@ -24,7 +24,7 @@ export default function TopNavMultipleDropdowns() { } />} - href="#" + headingHref="#" /> } startContent={ diff --git a/packages/cli/assets/templates/blocks/components/TopNav/TopNavWithLogo.tsx b/packages/cli/assets/templates/blocks/components/TopNav/TopNavWithLogo.tsx index 8b737c83eb39..2078dc808d4c 100644 --- a/packages/cli/assets/templates/blocks/components/TopNav/TopNavWithLogo.tsx +++ b/packages/cli/assets/templates/blocks/components/TopNav/TopNavWithLogo.tsx @@ -16,7 +16,7 @@ export default function TopNavWithLogo() { } />} - href="#" + headingHref="#" /> } startContent={ diff --git a/packages/cli/assets/templates/blocks/components/TopNavHeading/TopNavHeadingShowcase.tsx b/packages/cli/assets/templates/blocks/components/TopNavHeading/TopNavHeadingShowcase.tsx index c2daaf1bebb4..e3017248b0db 100644 --- a/packages/cli/assets/templates/blocks/components/TopNavHeading/TopNavHeadingShowcase.tsx +++ b/packages/cli/assets/templates/blocks/components/TopNavHeading/TopNavHeadingShowcase.tsx @@ -39,7 +39,11 @@ export default function TopNavHeadingShowcase() { } href="/" /> + } + headingHref="/" + /> } /> diff --git a/packages/cli/assets/templates/pages/shell-top-nav/page.tsx b/packages/cli/assets/templates/pages/shell-top-nav/page.tsx index e36dac56137d..b0365f6b720f 100644 --- a/packages/cli/assets/templates/pages/shell-top-nav/page.tsx +++ b/packages/cli/assets/templates/pages/shell-top-nav/page.tsx @@ -108,7 +108,7 @@ function MegaItems({items}: {items: MegaItem[]}) { title={item.name} description={item.tagline} icon={} - href="#" + headingHref="#" /> ))} @@ -146,7 +146,7 @@ export default function ShellTopNav() { logo={ } /> } - href="#" + headingHref="#" /> } centerContent={ From 36a2c8ff47815193fd89d97a5795531a882bcee2 Mon Sep 17 00:00:00 2001 From: cixzhang Date: Mon, 3 Aug 2026 01:53:04 +0000 Subject: [PATCH 4/5] chore: add deprecated API codemods --- .../next/__tests__/next-codemods.test.mjs | 64 ++++++++++++++ .../assets/codemods/transforms/next/index.mjs | 52 ++++++++++++ .../migrate-grid-minchildwidth-to-columns.mjs | 83 +++++++++++++++++++ .../next/migrate-lab-codeblock-imports.mjs | 52 ++++++++++++ ...rate-navmenuitem-to-navheadingmenuitem.mjs | 63 ++++++++++++++ .../remove-theme-transition-token-imports.mjs | 55 ++++++++++++ ...name-topnavheading-href-to-headinghref.mjs | 62 ++++++++++++++ .../TopNav/TopNavEnterpriseDashboard.tsx | 4 +- .../templates/pages/shell-top-nav/page.tsx | 2 +- 9 files changed, 434 insertions(+), 3 deletions(-) create mode 100644 packages/cli/assets/codemods/transforms/next/__tests__/next-codemods.test.mjs create mode 100644 packages/cli/assets/codemods/transforms/next/index.mjs create mode 100644 packages/cli/assets/codemods/transforms/next/migrate-grid-minchildwidth-to-columns.mjs create mode 100644 packages/cli/assets/codemods/transforms/next/migrate-lab-codeblock-imports.mjs create mode 100644 packages/cli/assets/codemods/transforms/next/migrate-navmenuitem-to-navheadingmenuitem.mjs create mode 100644 packages/cli/assets/codemods/transforms/next/remove-theme-transition-token-imports.mjs create mode 100644 packages/cli/assets/codemods/transforms/next/rename-topnavheading-href-to-headinghref.mjs diff --git a/packages/cli/assets/codemods/transforms/next/__tests__/next-codemods.test.mjs b/packages/cli/assets/codemods/transforms/next/__tests__/next-codemods.test.mjs new file mode 100644 index 000000000000..0e0c01c9ce63 --- /dev/null +++ b/packages/cli/assets/codemods/transforms/next/__tests__/next-codemods.test.mjs @@ -0,0 +1,64 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +import {describe, expect, it} from 'vitest'; +import jscodeshift from 'jscodeshift'; + +const j = jscodeshift.withParser('tsx'); +const api = {jscodeshift: j, stats: () => {}, report: () => {}}; +async function apply(name, source) { + const {default: transform} = await import(`../${name}.mjs`); + return transform({source, path: 'test.tsx'}, api) ?? source; +} + +describe('next codemods', () => { + it('renames TopNavHeading href to headingHref without touching TopNavItem', async () => { + const input = `import {TopNavHeading, TopNavItem} from '@astryxdesign/core/TopNav'; +const x = <>;`; + const output = await apply( + 'rename-topnavheading-href-to-headinghref', + input, + ); + expect(output).toContain('headingHref="/"'); + expect(output).toContain(' { + const input = `import {Grid} from '@astryxdesign/core/Grid'; +const x = ;`; + const output = await apply('migrate-grid-minchildwidth-to-columns', input); + expect(output).toContain('columns={{'); + expect(output).toContain('minWidth: 160'); + expect(output).toContain("repeat: 'fit'"); + expect(output).not.toContain('minChildWidth'); + }); + + it('renames NavMenuItem imports and JSX', async () => { + const input = `import {NavMenuItem, type NavMenuItemProps} from '@astryxdesign/core/NavMenu'; +const item: NavMenuItemProps = {}; +const x = ;`; + const output = await apply( + 'migrate-navmenuitem-to-navheadingmenuitem', + input, + ); + expect(output).toContain('NavHeadingMenuItem'); + expect(output).toContain('NavHeadingMenuItemProps'); + expect(output).not.toContain('NavMenuItem'); + }); + + it('repoints lab CodeBlock imports to core CodeBlock subpath', async () => { + const input = `import {CodeBlock, Drawer} from '@astryxdesign/lab'; +const x = ;`; + const output = await apply('migrate-lab-codeblock-imports', input); + expect(output).toContain("from '@astryxdesign/core/CodeBlock'"); + expect(output).toMatch(/import \{\s*Drawer\s*\} from '@astryxdesign\/lab'/); + }); + + it('removes deprecated transition token imports', async () => { + const input = `import {transitionVars, durationVars, type TransitionVarName} from '@astryxdesign/core/theme'; +const d = durationVars;`; + const output = await apply('remove-theme-transition-token-imports', input); + expect(output).toContain('durationVars'); + expect(output).not.toContain('transitionVars'); + expect(output).not.toContain('TransitionVarName'); + }); +}); diff --git a/packages/cli/assets/codemods/transforms/next/index.mjs b/packages/cli/assets/codemods/transforms/next/index.mjs new file mode 100644 index 000000000000..bb87aaa23e57 --- /dev/null +++ b/packages/cli/assets/codemods/transforms/next/index.mjs @@ -0,0 +1,52 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file next transform manifest + * + * Staged codemods for the next release. The Version Packages PR promotes this + * file into the resolved version folder. + */ + +import renameTopNavHeadingHrefToHeadingHref, { + meta as renameTopNavHeadingHrefToHeadingHrefMeta, +} from './rename-topnavheading-href-to-headinghref.mjs'; +import migrateGridMinChildWidthToColumns, { + meta as migrateGridMinChildWidthToColumnsMeta, +} from './migrate-grid-minchildwidth-to-columns.mjs'; +import migrateNavMenuItemToNavHeadingMenuItem, { + meta as migrateNavMenuItemToNavHeadingMenuItemMeta, +} from './migrate-navmenuitem-to-navheadingmenuitem.mjs'; +import migrateLabCodeBlockImports, { + meta as migrateLabCodeBlockImportsMeta, +} from './migrate-lab-codeblock-imports.mjs'; +import removeThemeTransitionTokenImports, { + meta as removeThemeTransitionTokenImportsMeta, +} from './remove-theme-transition-token-imports.mjs'; + +export default [ + { + name: 'rename-topnavheading-href-to-headinghref', + transform: renameTopNavHeadingHrefToHeadingHref, + meta: renameTopNavHeadingHrefToHeadingHrefMeta, + }, + { + name: 'migrate-grid-minchildwidth-to-columns', + transform: migrateGridMinChildWidthToColumns, + meta: migrateGridMinChildWidthToColumnsMeta, + }, + { + name: 'migrate-navmenuitem-to-navheadingmenuitem', + transform: migrateNavMenuItemToNavHeadingMenuItem, + meta: migrateNavMenuItemToNavHeadingMenuItemMeta, + }, + { + name: 'migrate-lab-codeblock-imports', + transform: migrateLabCodeBlockImports, + meta: migrateLabCodeBlockImportsMeta, + }, + { + name: 'remove-theme-transition-token-imports', + transform: removeThemeTransitionTokenImports, + meta: removeThemeTransitionTokenImportsMeta, + }, +]; diff --git a/packages/cli/assets/codemods/transforms/next/migrate-grid-minchildwidth-to-columns.mjs b/packages/cli/assets/codemods/transforms/next/migrate-grid-minchildwidth-to-columns.mjs new file mode 100644 index 000000000000..46d6dd5feb6c --- /dev/null +++ b/packages/cli/assets/codemods/transforms/next/migrate-grid-minchildwidth-to-columns.mjs @@ -0,0 +1,83 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file Codemod: Migrate Grid minChildWidth to columns.minWidth + */ + +export const meta = { + title: 'Migrate Grid minChildWidth to columns.minWidth', + description: + 'Rewrites to where safe.', + pr: '#4657', +}; + +const IMPORT_SOURCES = new Set([ + '@astryxdesign/core', + '@astryxdesign/core/Grid', + '@xds/core', + '@xds/core/Grid', +]); + +/** + * @param {import('../../../../authoring/codemod/type').AstryxCodemodFile} file + * @param {import('../../../../authoring/codemod/type').CodemodTransformApi} api + * @returns {string | null | undefined} + */ +export default function transformer(file, api) { + const j = api.jscodeshift; + const root = j(file.source); + const locals = new Set(); + let hasChanges = false; + + root.find(j.ImportDeclaration).forEach((/** @type {any} */ path) => { + if (!IMPORT_SOURCES.has(path.node.source.value)) return; + for (const spec of path.node.specifiers ?? []) { + if (spec.type === 'ImportSpecifier' && spec.imported.name === 'Grid') { + locals.add(spec.local.name); + } + } + }); + if (locals.size === 0) return undefined; + + root.find(j.JSXOpeningElement).forEach((/** @type {any} */ path) => { + const name = path.node.name; + const componentName = name.type === 'JSXIdentifier' ? name.name : null; + if (!componentName || !locals.has(componentName)) return; + const attrs = path.node.attributes ?? []; + const minAttr = attrs.find( + (/** @type {any} */ a) => + a.type === 'JSXAttribute' && a.name?.name === 'minChildWidth', + ); + if (!minAttr) return; + const hasColumns = attrs.some( + (/** @type {any} */ a) => + a.type === 'JSXAttribute' && a.name?.name === 'columns', + ); + if (hasColumns) return; + + let minWidthExpr; + if ( + minAttr.value?.type === 'StringLiteral' || + minAttr.value?.type === 'Literal' + ) { + minWidthExpr = j.literal(Number(minAttr.value.value)); + } else if (minAttr.value?.type === 'JSXExpressionContainer') { + minWidthExpr = minAttr.value.expression; + } else { + return; + } + + const columnsObject = j.objectExpression([ + j.property('init', j.identifier('minWidth'), minWidthExpr), + j.property('init', j.identifier('repeat'), j.literal('fit')), + ]); + const replacement = j.jsxAttribute( + j.jsxIdentifier('columns'), + j.jsxExpressionContainer(columnsObject), + ); + attrs.splice(attrs.indexOf(minAttr), 1, replacement); + hasChanges = true; + }); + + return hasChanges ? root.toSource({quote: 'single'}) : undefined; +} diff --git a/packages/cli/assets/codemods/transforms/next/migrate-lab-codeblock-imports.mjs b/packages/cli/assets/codemods/transforms/next/migrate-lab-codeblock-imports.mjs new file mode 100644 index 000000000000..0d35cfc4c6fc --- /dev/null +++ b/packages/cli/assets/codemods/transforms/next/migrate-lab-codeblock-imports.mjs @@ -0,0 +1,52 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file Codemod: Repoint lab CodeBlock imports to core + */ + +export const meta = { + title: 'Repoint lab CodeBlock imports to core', + description: + 'Moves CodeBlock imports from @astryxdesign/lab to @astryxdesign/core/CodeBlock.', + pr: '#4657', +}; + +const CODEBLOCK_EXPORTS = new Set(['CodeBlock', 'CodeBlockProps']); + +/** + * @param {import('../../../../authoring/codemod/type').AstryxCodemodFile} file + * @param {import('../../../../authoring/codemod/type').CodemodTransformApi} api + * @returns {string | null | undefined} + */ +export default function transformer(file, api) { + const j = api.jscodeshift; + const root = j(file.source); + let hasChanges = false; + + root.find(j.ImportDeclaration).forEach((/** @type {any} */ path) => { + if (path.node.source.value !== '@astryxdesign/lab') return; + const codeBlockSpecs = []; + const remainingSpecs = []; + for (const spec of path.node.specifiers ?? []) { + if ( + spec.type === 'ImportSpecifier' && + CODEBLOCK_EXPORTS.has(spec.imported.name) + ) { + codeBlockSpecs.push(spec); + } else { + remainingSpecs.push(spec); + } + } + if (codeBlockSpecs.length === 0) return; + path.node.specifiers = remainingSpecs; + const coreImport = j.importDeclaration( + codeBlockSpecs, + j.literal('@astryxdesign/core/CodeBlock'), + ); + j(path).insertBefore(coreImport); + if (remainingSpecs.length === 0) j(path).remove(); + hasChanges = true; + }); + + return hasChanges ? root.toSource({quote: 'single'}) : undefined; +} diff --git a/packages/cli/assets/codemods/transforms/next/migrate-navmenuitem-to-navheadingmenuitem.mjs b/packages/cli/assets/codemods/transforms/next/migrate-navmenuitem-to-navheadingmenuitem.mjs new file mode 100644 index 000000000000..e3174969c2dd --- /dev/null +++ b/packages/cli/assets/codemods/transforms/next/migrate-navmenuitem-to-navheadingmenuitem.mjs @@ -0,0 +1,63 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file Codemod: Rename NavMenuItem to NavHeadingMenuItem + */ + +export const meta = { + title: 'Rename NavMenuItem to NavHeadingMenuItem', + description: + 'Rewrites the removed NavMenuItem alias and its props type to NavHeadingMenuItem.', + pr: '#4657', +}; + +const IMPORT_SOURCES = new Set([ + '@astryxdesign/core', + '@astryxdesign/core/NavMenu', + '@xds/core', + '@xds/core/NavMenu', +]); + +/** + * @param {import('../../../../authoring/codemod/type').AstryxCodemodFile} file + * @param {import('../../../../authoring/codemod/type').CodemodTransformApi} api + * @returns {string | null | undefined} + */ +export default function transformer(file, api) { + const j = api.jscodeshift; + const root = j(file.source); + const renames = new Map(); + let hasChanges = false; + + root.find(j.ImportDeclaration).forEach((/** @type {any} */ path) => { + if (!IMPORT_SOURCES.has(path.node.source.value)) return; + for (const spec of path.node.specifiers ?? []) { + if (spec.type !== 'ImportSpecifier') continue; + if ( + spec.imported.name === 'NavMenuItem' || + spec.imported.name === 'NavMenuItemProps' + ) { + const oldLocal = spec.local?.name ?? spec.imported.name; + const nextName = + spec.imported.name === 'NavMenuItem' + ? 'NavHeadingMenuItem' + : 'NavHeadingMenuItemProps'; + spec.imported.name = nextName; + spec.local = j.identifier(nextName); + renames.set(oldLocal, nextName); + hasChanges = true; + } + } + }); + + for (const [oldName, nextName] of renames) { + root + .find(j.JSXIdentifier, {name: oldName}) + .replaceWith(() => j.jsxIdentifier(nextName)); + root + .find(j.Identifier, {name: oldName}) + .replaceWith(() => j.identifier(nextName)); + } + + return hasChanges ? root.toSource({quote: 'single'}) : undefined; +} diff --git a/packages/cli/assets/codemods/transforms/next/remove-theme-transition-token-imports.mjs b/packages/cli/assets/codemods/transforms/next/remove-theme-transition-token-imports.mjs new file mode 100644 index 000000000000..7861ad687fbb --- /dev/null +++ b/packages/cli/assets/codemods/transforms/next/remove-theme-transition-token-imports.mjs @@ -0,0 +1,55 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file Codemod: Remove deprecated transition token imports + */ + +export const meta = { + title: 'Remove deprecated transition token imports', + description: + 'Removes import specifiers for transitionDefaults, transitionRaw, transitionVars, and TransitionVarName.', + pr: '#4657', +}; + +const REMOVED = new Set([ + 'transitionDefaults', + 'transitionRaw', + 'transitionVars', + 'TransitionVarName', +]); +const SOURCES = new Set([ + '@astryxdesign/core', + '@astryxdesign/core/theme', + '@astryxdesign/core/theme/tokens.stylex', + '@xds/core', + '@xds/core/theme', + '@xds/core/theme/tokens.stylex', +]); + +/** + * @param {import('../../../../authoring/codemod/type').AstryxCodemodFile} file + * @param {import('../../../../authoring/codemod/type').CodemodTransformApi} api + * @returns {string | null | undefined} + */ +export default function transformer(file, api) { + const j = api.jscodeshift; + const root = j(file.source); + let hasChanges = false; + + root.find(j.ImportDeclaration).forEach((/** @type {any} */ path) => { + if (!SOURCES.has(path.node.source.value)) return; + const before = path.node.specifiers?.length ?? 0; + path.node.specifiers = (path.node.specifiers ?? []).filter( + (/** @type {any} */ spec) => { + if (spec.type !== 'ImportSpecifier') return true; + return !REMOVED.has(spec.imported.name); + }, + ); + if ((path.node.specifiers?.length ?? 0) !== before) { + hasChanges = true; + if ((path.node.specifiers?.length ?? 0) === 0) j(path).remove(); + } + }); + + return hasChanges ? root.toSource({quote: 'single'}) : undefined; +} diff --git a/packages/cli/assets/codemods/transforms/next/rename-topnavheading-href-to-headinghref.mjs b/packages/cli/assets/codemods/transforms/next/rename-topnavheading-href-to-headinghref.mjs new file mode 100644 index 000000000000..100d94dffc27 --- /dev/null +++ b/packages/cli/assets/codemods/transforms/next/rename-topnavheading-href-to-headinghref.mjs @@ -0,0 +1,62 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file Codemod: Rename TopNavHeading href to headingHref + */ + +export const meta = { + title: 'Rename TopNavHeading href to headingHref', + description: 'Renames the removed TopNavHeading href alias to headingHref.', + pr: '#4657', +}; + +const IMPORT_SOURCES = new Set([ + '@astryxdesign/core', + '@astryxdesign/core/TopNav', + '@xds/core', + '@xds/core/TopNav', +]); + +/** + * @param {import('../../../../authoring/codemod/type').AstryxCodemodFile} file + * @param {import('../../../../authoring/codemod/type').CodemodTransformApi} api + * @returns {string | null | undefined} + */ +export default function transformer(file, api) { + const j = api.jscodeshift; + const root = j(file.source); + const locals = new Set(); + let hasChanges = false; + + root.find(j.ImportDeclaration).forEach((/** @type {any} */ path) => { + if (!IMPORT_SOURCES.has(path.node.source.value)) return; + for (const spec of path.node.specifiers ?? []) { + if ( + spec.type === 'ImportSpecifier' && + spec.imported.name === 'TopNavHeading' + ) { + locals.add(spec.local.name); + } + } + }); + if (locals.size === 0) return undefined; + + root.find(j.JSXOpeningElement).forEach((/** @type {any} */ path) => { + const name = path.node.name; + const componentName = name.type === 'JSXIdentifier' ? name.name : null; + if (!componentName || !locals.has(componentName)) return; + for (const attr of path.node.attributes ?? []) { + if (attr.type !== 'JSXAttribute') continue; + if (attr.name?.name !== 'href') continue; + const hasHeadingHref = path.node.attributes.some( + (/** @type {any} */ a) => + a.type === 'JSXAttribute' && a.name?.name === 'headingHref', + ); + if (hasHeadingHref) continue; + attr.name.name = 'headingHref'; + hasChanges = true; + } + }); + + return hasChanges ? root.toSource({quote: 'single'}) : undefined; +} diff --git a/packages/cli/assets/templates/blocks/components/TopNav/TopNavEnterpriseDashboard.tsx b/packages/cli/assets/templates/blocks/components/TopNav/TopNavEnterpriseDashboard.tsx index 24c543749a30..073164c94155 100644 --- a/packages/cli/assets/templates/blocks/components/TopNav/TopNavEnterpriseDashboard.tsx +++ b/packages/cli/assets/templates/blocks/components/TopNav/TopNavEnterpriseDashboard.tsx @@ -28,13 +28,13 @@ export default function TopNavEnterpriseDashboard() { <> } /> } /> diff --git a/packages/cli/assets/templates/pages/shell-top-nav/page.tsx b/packages/cli/assets/templates/pages/shell-top-nav/page.tsx index b0365f6b720f..4cc8304611e4 100644 --- a/packages/cli/assets/templates/pages/shell-top-nav/page.tsx +++ b/packages/cli/assets/templates/pages/shell-top-nav/page.tsx @@ -108,7 +108,7 @@ function MegaItems({items}: {items: MegaItem[]}) { title={item.name} description={item.tagline} icon={} - headingHref="#" + href="#" /> ))} From a7437e1bec7a9d7c5038d8602d61cff0296aca19 Mon Sep 17 00:00:00 2001 From: cixzhang Date: Tue, 4 Aug 2026 02:14:53 +0000 Subject: [PATCH 5/5] chore: keep table row expansion deprecated API --- .../app/(sandbox)/pages/table-lab/page.tsx | 20 +- .../stories/TableRowExpansion.stories.tsx | 64 +- .../Table/TableRowExpansionTable.doc.mjs | 14 + .../Table/TableRowExpansionTable.tsx | 99 +++ packages/core/src/Table/index.ts | 5 + .../src/Table/plugins/rowExpansion/index.ts | 7 + .../useTableRowExpansion.test.tsx | 263 +++++++ .../rowExpansion/useTableRowExpansion.tsx | 650 ++++++++++++++++++ .../src/Table/useTableGroupedRows.doc.mjs | 2 +- .../src/Table/useTableRowExpansion.doc.mjs | 133 ++++ 10 files changed, 1214 insertions(+), 43 deletions(-) create mode 100644 packages/cli/assets/templates/blocks/components/Table/TableRowExpansionTable.doc.mjs create mode 100644 packages/cli/assets/templates/blocks/components/Table/TableRowExpansionTable.tsx create mode 100644 packages/core/src/Table/plugins/rowExpansion/index.ts create mode 100644 packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.test.tsx create mode 100644 packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx create mode 100644 packages/core/src/Table/useTableRowExpansion.doc.mjs diff --git a/apps/sandbox/src/app/(sandbox)/pages/table-lab/page.tsx b/apps/sandbox/src/app/(sandbox)/pages/table-lab/page.tsx index b73e34823192..141f0a593166 100644 --- a/apps/sandbox/src/app/(sandbox)/pages/table-lab/page.tsx +++ b/apps/sandbox/src/app/(sandbox)/pages/table-lab/page.tsx @@ -45,8 +45,8 @@ import { paginateData, useTableColumnResize, useTableStickyColumns, - useTableTreeData, - useTableTreeState, + useTableRowExpansion, + useTableRowExpansionState, useTableGroupedRows, useTableRowIndex, useTableRowStatus, @@ -205,16 +205,16 @@ function useLabPlugins({ endKeys: ['joined'], }); - // --- tree rows (flat rows; no real tree in the synthetic data) --- + // --- row expansion (flat rows; no real tree in the synthetic data) --- const [expandedKeys, setExpandedKeys] = useState>(new Set()); - const {visibleData, treeConfig} = useTableTreeState({ - data: dataAfterPage, - idKey: 'id', - expandedIds: expandedKeys, - onExpandedIdsChange: ids => setExpandedKeys(new Set(ids)), - isItemExpandable: () => false, + const {expansionConfig} = useTableRowExpansionState({ + baseData: dataAfterPage, + getChildren: () => [], + getRowKey: item => item.id, + expandedKeys, + setExpandedKeys, }); - const treePlugin = useTableTreeData(treeConfig); + const rowExpansionPlugin = useTableRowExpansion(expansionConfig); if (enabled.rowExpansion) { summary.push(`${expandedKeys.size} expanded`); } diff --git a/apps/storybook/stories/TableRowExpansion.stories.tsx b/apps/storybook/stories/TableRowExpansion.stories.tsx index 29e79c71a8b3..be1f12c54d10 100644 --- a/apps/storybook/stories/TableRowExpansion.stories.tsx +++ b/apps/storybook/stories/TableRowExpansion.stories.tsx @@ -4,8 +4,8 @@ import {useState} from 'react'; import type {Meta, StoryObj} from '@storybook/react'; import { Table, - useTableTreeData, - useTableTreeState, + useTableRowExpansion, + useTableRowExpansionState, pixel, proportional, } from '@astryxdesign/core/Table'; @@ -147,7 +147,7 @@ const columns: TableColumn[] = [ // ============================================================================= const meta: Meta = { - title: 'Core/TableTree', + title: 'Core/TableRowExpansion', tags: ['autodocs'], }; @@ -167,23 +167,23 @@ export const InheritedColumns: Story = { // The state hook flattens the tree, tracks depth, and derives the // expand/collapse + expand-all handlers — no boilerplate in the consumer. - const {data, treeConfig} = useTableTreeState({ - data: fileTree, - idKey: 'id', - childrenKey: 'children', - expandedIds: expandedKeys, - onExpandedIdsChange: ids => setExpandedKeys(new Set(ids)), + const {data, expansionConfig} = useTableRowExpansionState({ + baseData: fileTree, + getChildren: item => item.children ?? [], + getRowKey: item => item.id, + expandedKeys, + setExpandedKeys, }); - const tree = useTableTreeData(treeConfig); + const expansion = useTableRowExpansion(expansionConfig); return (
); }, @@ -200,24 +200,24 @@ export const LeafNodesNotExpandable: Story = { ); // `getIsItemExpandable` restricts expandability (and expand-all) to folders. - const {data, treeConfig} = useTableTreeState({ - data: fileTree, - idKey: 'id', - childrenKey: 'children', - expandedIds: expandedKeys, - onExpandedIdsChange: ids => setExpandedKeys(new Set(ids)), - isItemExpandable: item => item.type === 'folder', + const {data, expansionConfig} = useTableRowExpansionState({ + baseData: fileTree, + getChildren: item => item.children ?? [], + getRowKey: item => item.id, + getIsItemExpandable: item => item.type === 'folder', + expandedKeys, + setExpandedKeys, }); - const tree = useTableTreeData(treeConfig); + const expansion = useTableRowExpansion(expansionConfig); return (
); }, @@ -231,27 +231,27 @@ export const ExpandOnRowClick: Story = { render: () => { const [expandedKeys, setExpandedKeys] = useState>(new Set()); - const {data, treeConfig} = useTableTreeState({ - data: fileTree, - idKey: 'id', - childrenKey: 'children', - expandedIds: expandedKeys, - onExpandedIdsChange: ids => setExpandedKeys(new Set(ids)), + const {data, expansionConfig} = useTableRowExpansionState({ + baseData: fileTree, + getChildren: item => item.children ?? [], + getRowKey: item => item.id, + expandedKeys, + setExpandedKeys, }); // Opt into row-click expansion by extending the derived config. - const tree = useTableTreeData({ - ...treeConfig, + const expansion = useTableRowExpansion({ + ...expansionConfig, hasRowClickExpansion: true, }); return (
); }, diff --git a/packages/cli/assets/templates/blocks/components/Table/TableRowExpansionTable.doc.mjs b/packages/cli/assets/templates/blocks/components/Table/TableRowExpansionTable.doc.mjs new file mode 100644 index 000000000000..b09ac37d14b6 --- /dev/null +++ b/packages/cli/assets/templates/blocks/components/Table/TableRowExpansionTable.doc.mjs @@ -0,0 +1,14 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** @type {import('@astryxdesign/cli/authoring').TemplateDoc} */ +export const doc = { + type: 'block', + exampleFor: 'useTableRowExpansion', + name: 'useTableRowExpansion — Tree Table', + displayName: 'useTableRowExpansion — Tree Table', + description: + 'A tree table using useTableRowExpansion with inherited columns. Child rows use the same columns as parents, indented by depth. Click the chevron or right-click to expand/collapse.', + isReady: true, + aspectRatio: 16 / 9, + componentsUsed: ['Table'], +}; diff --git a/packages/cli/assets/templates/blocks/components/Table/TableRowExpansionTable.tsx b/packages/cli/assets/templates/blocks/components/Table/TableRowExpansionTable.tsx new file mode 100644 index 000000000000..d404f1c50951 --- /dev/null +++ b/packages/cli/assets/templates/blocks/components/Table/TableRowExpansionTable.tsx @@ -0,0 +1,99 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +import {useState} from 'react'; +import { + Table, + useTableRowExpansion, + useTableRowExpansionState, + proportional, + pixel, +} from '@astryxdesign/core/Table'; + +interface FileNode extends Record { + id: string; + name: string; + type: 'folder' | 'file'; + size: string; + children?: FileNode[]; +} + +const fileTree: FileNode[] = [ + { + id: 'src', + name: 'src', + type: 'folder', + size: '—', + children: [ + { + id: 'src/components', + name: 'components', + type: 'folder', + size: '—', + children: [ + { + id: 'src/components/Button.tsx', + name: 'Button.tsx', + type: 'file', + size: '4.2 KB', + children: [], + }, + { + id: 'src/components/Table.tsx', + name: 'Table.tsx', + type: 'file', + size: '12.8 KB', + children: [], + }, + ], + }, + { + id: 'src/index.ts', + name: 'index.ts', + type: 'file', + size: '0.4 KB', + children: [], + }, + ], + }, + { + id: 'package.json', + name: 'package.json', + type: 'file', + size: '1.8 KB', + children: [], + }, +]; + +const columns = [ + {key: 'name', header: 'Name', width: proportional(2)}, + {key: 'type', header: 'Type', width: pixel(80)}, + {key: 'size', header: 'Size', width: pixel(90)}, +]; + +export default function TableRowExpansionTable() { + const [expandedKeys, setExpandedKeys] = useState>( + new Set(['src']), + ); + + const {data, expansionConfig} = useTableRowExpansionState({ + baseData: fileTree, + getChildren: item => item.children ?? [], + getRowKey: item => item.id, + expandedKeys, + setExpandedKeys, + }); + + const expansion = useTableRowExpansion(expansionConfig); + + return ( +
+ ); +} diff --git a/packages/core/src/Table/index.ts b/packages/core/src/Table/index.ts index 4ce004ed540c..9f9f731121fb 100644 --- a/packages/core/src/Table/index.ts +++ b/packages/core/src/Table/index.ts @@ -28,6 +28,10 @@ export {useTableColumnResize} from './plugins/columnResize'; export {useTableStickyColumns} from './plugins/stickyColumns'; export {useTableGroupedRows} from './plugins/groupedRows'; export {useTableRowIndex} from './plugins/rowIndex'; +export { + useTableRowExpansion, + useTableRowExpansionState, +} from './plugins/rowExpansion'; export {useTableRowStatus} from './plugins/rowStatus'; export {useTableTreeData, useTableTreeState} from './plugins/tree'; export {resolveContextActions} from './tableContextMenu'; @@ -101,6 +105,7 @@ export type { } from './plugins/columnSettings'; export type {UseTableColumnResizeConfig} from './plugins/columnResize'; export type {UseTableStickyColumnsConfig} from './plugins/stickyColumns'; +export type {UseTableRowExpansionConfig} from './plugins/rowExpansion'; export type {UseTableRowIndexConfig} from './plugins/rowIndex'; export type { UseTableGroupedRowsConfig, diff --git a/packages/core/src/Table/plugins/rowExpansion/index.ts b/packages/core/src/Table/plugins/rowExpansion/index.ts new file mode 100644 index 000000000000..ec9f08a7bf24 --- /dev/null +++ b/packages/core/src/Table/plugins/rowExpansion/index.ts @@ -0,0 +1,7 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +export { + useTableRowExpansion, + useTableRowExpansionState, +} from './useTableRowExpansion'; +export type {UseTableRowExpansionConfig} from './useTableRowExpansion'; diff --git a/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.test.tsx b/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.test.tsx new file mode 100644 index 000000000000..2c040a8e28bd --- /dev/null +++ b/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.test.tsx @@ -0,0 +1,263 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +import {describe, it, expect, vi, beforeEach} from 'vitest'; +import {render, renderHook, screen, fireEvent} from '@testing-library/react'; +import {useState} from 'react'; +import {Table} from '../../Table'; +import type {TableColumn} from '../../types'; +import { + useTableRowExpansion, + useTableRowExpansionState, +} from './useTableRowExpansion'; +import {InternationalizationProvider} from '../../../i18n'; + +// popover mock for context-menu tests +beforeEach(() => { + HTMLElement.prototype.showPopover = vi.fn(function (this: HTMLElement) { + this.setAttribute('popover-open', ''); + const event = new Event('toggle', {bubbles: false}); + Object.defineProperty(event, 'newState', {value: 'open'}); + this.dispatchEvent(event); + }); + HTMLElement.prototype.hidePopover = vi.fn(function (this: HTMLElement) { + this.removeAttribute('popover-open'); + const event = new Event('toggle', {bubbles: false}); + Object.defineProperty(event, 'newState', {value: 'closed'}); + this.dispatchEvent(event); + }); + const originalMatches = HTMLElement.prototype.matches; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (HTMLElement.prototype as any).matches = function ( + selector: string, + ): boolean { + if (selector === ':popover-open') { + return this.hasAttribute('popover-open'); + } + return originalMatches.call(this, selector); + }; +}); + +interface TreeItem extends Record { + id: string; + name: string; + children: TreeItem[]; +} + +const treeData: TreeItem[] = [ + { + id: 'a', + name: 'Folder A', + children: [ + {id: 'a1', name: 'File A1', children: []}, + {id: 'a2', name: 'File A2', children: []}, + ], + }, + { + id: 'b', + name: 'Folder B', + children: [{id: 'b1', name: 'File B1', children: []}], + }, + {id: 'c', name: 'Leaf C', children: []}, +]; + +const columns: TableColumn[] = [{key: 'name', header: 'Name'}]; + +const EMPTY_KEYS = new Set(); + +function Harness({ + initialExpanded = EMPTY_KEYS, +}: { + initialExpanded?: Set; +}) { + const [expandedKeys, setExpandedKeys] = useState(initialExpanded); + const {data, expansionConfig} = useTableRowExpansionState({ + baseData: treeData, + getChildren: item => item.children, + getRowKey: item => item.id, + expandedKeys, + setExpandedKeys, + }); + const expansion = useTableRowExpansion(expansionConfig); + return ( +
+ ); +} + +describe('useTableRowExpansion', () => { + it('renders a chevron button for expandable rows', () => { + render(); + const buttons = screen.getAllByRole('button', {name: /expand row/i}); + // Folder A and Folder B are expandable (top-level with children) + expect(buttons.length).toBe(2); + }); + + it('shows child rows when expanded', () => { + render(); + expect(screen.getByText('File A1')).toBeInTheDocument(); + expect(screen.getByText('File A2')).toBeInTheDocument(); + }); + + it('hides child rows when collapsed', () => { + render(); + expect(screen.queryByText('File A1')).not.toBeInTheDocument(); + }); + + it('toggles expansion on chevron click', () => { + render(); + expect(screen.queryByText('File A1')).not.toBeInTheDocument(); + fireEvent.click(screen.getAllByRole('button', {name: /expand row/i})[0]); + expect(screen.getByText('File A1')).toBeInTheDocument(); + }); + + it('contributes a context-menu action on expandable rows', () => { + render(); + fireEvent.contextMenu(screen.getByText('Folder A')); + const items = screen.getAllByRole('menuitem', { + name: /expand row/i, + hidden: true, + }); + expect(items.length).toBeGreaterThan(0); + }); + + it('localizes the context-menu action label through the i18n catalog', () => { + render( + + + , + ); + fireEvent.contextMenu(screen.getByText('Folder A')); + const items = screen.getAllByRole('menuitem', { + name: /Développer la ligne/, + hidden: true, + }); + expect(items.length).toBeGreaterThan(0); + }); + + it('does not show chevron for leaf nodes', () => { + render(); + // Leaf C has no children + expect(screen.getByText('Leaf C')).toBeInTheDocument(); + // only 2 expand buttons (Folder A, Folder B), not 3 + expect( + screen.getAllByRole('button', {name: /expand row|collapse row/i}).length, + ).toBe(2); + }); + + it('renders an expand-all toggle in the header', () => { + render(); + expect( + screen.getByRole('button', {name: /expand all rows/i}), + ).toBeInTheDocument(); + }); + + it('expand-all toggle expands every expandable row', () => { + render(); + expect(screen.queryByText('File A1')).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', {name: /expand all rows/i})); + // Both folders' children become visible. + expect(screen.getByText('File A1')).toBeInTheDocument(); + expect(screen.getByText('File A2')).toBeInTheDocument(); + expect(screen.getByText('File B1')).toBeInTheDocument(); + }); + + it('collapse-all toggle collapses every row', () => { + render(); + expect(screen.getByText('File A1')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', {name: /collapse all rows/i})); + expect(screen.queryByText('File A1')).not.toBeInTheDocument(); + expect(screen.queryByText('File B1')).not.toBeInTheDocument(); + }); +}); + +describe('useTableRowExpansionState cycle guard', () => { + /** A row whose children array contains the row itself (plus a real child). */ + function makeSelfReferential(): TreeItem[] { + const x: TreeItem = {id: 'x', name: 'Self', children: []}; + const y: TreeItem = {id: 'y', name: 'Leaf Y', children: []}; + x.children.push(x, y); + return [x]; + } + + /** root -> child -> grand, where grand's children point back at root. */ + function makeDeepCycle(): TreeItem[] { + const root: TreeItem = {id: 'root', name: 'Root', children: []}; + const child: TreeItem = {id: 'child', name: 'Child', children: []}; + const grand: TreeItem = {id: 'grand', name: 'Grand', children: []}; + root.children.push(child); + child.children.push(grand); + grand.children.push(root); + return [root]; + } + + function renderState(baseData: TreeItem[], expandedKeys: Set) { + const setExpandedKeys = vi.fn(); + const hook = renderHook(() => + useTableRowExpansionState({ + baseData, + getChildren: item => item.children, + getRowKey: item => item.id, + expandedKeys, + setExpandedKeys, + }), + ); + return {hook, setExpandedKeys}; + } + + it('terminates on a self-referential expanded row and flattens each key once', () => { + const {hook} = renderState(makeSelfReferential(), new Set(['x'])); + const {data, expansionConfig} = hook.result.current; + expect(data.map(item => item.id)).toEqual(['x', 'y']); + expect(expansionConfig.getDepth?.(data[0])).toBe(0); + expect(expansionConfig.getDepth?.(data[1])).toBe(1); + }); + + it('terminates on a deeper cycle back to the root and flattens each key once', () => { + const {hook} = renderState( + makeDeepCycle(), + new Set(['root', 'child', 'grand']), + ); + const {data, expansionConfig} = hook.result.current; + expect(data.map(item => item.id)).toEqual(['root', 'child', 'grand']); + expect(expansionConfig.getDepth?.(data[0])).toBe(0); + expect(expansionConfig.getDepth?.(data[1])).toBe(1); + expect(expansionConfig.getDepth?.(data[2])).toBe(2); + // The cycle guard keeps isAllExpanded computable — true, not a crash. + expect(expansionConfig.isAllExpanded).toBe(true); + }); + + it('terminates when collecting allExpandableKeys on cyclic data with nothing expanded', () => { + const {hook, setExpandedKeys} = renderState( + makeDeepCycle(), + new Set(), + ); + const {data, expansionConfig} = hook.result.current; + expect(data.map(item => item.id)).toEqual(['root']); + expansionConfig.onToggleExpandAll?.(true); + const nextKeys = setExpandedKeys.mock.calls[0][0] as Set; + expect(Array.from(nextKeys)).toEqual(['root', 'child', 'grand']); + }); + + it('re-walks a shared child under each expanded parent (ancestor-path, not visited-set, semantics)', () => { + const leaf: TreeItem = {id: 'leaf', name: 'Leaf', children: []}; + const shared: TreeItem = {id: 's', name: 'Shared', children: [leaf]}; + const p1: TreeItem = {id: 'p1', name: 'Parent 1', children: [shared]}; + const p2: TreeItem = {id: 'p2', name: 'Parent 2', children: [shared]}; + const {hook} = renderState([p1, p2], new Set(['p1', 'p2', 's'])); + // 's' is on neither parent's ancestor path, so it must flatten under + // both — the guard only skips true cycles, matching pre-guard behavior + // for acyclic (DAG-shaped) data. + const {data} = hook.result.current; + expect(data.map(item => item.id)).toEqual([ + 'p1', + 's', + 'leaf', + 'p2', + 's', + 'leaf', + ]); + }); +}); diff --git a/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx b/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx new file mode 100644 index 000000000000..fb745fac53ba --- /dev/null +++ b/packages/core/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx @@ -0,0 +1,650 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +'use client'; + +/** + * @file useTableRowExpansion.tsx + * @input React, StyleX, Icon, Table types + * @output Exports useTableRowExpansion hook + config/state types + * @position Row-expansion plugin; consumed by Table via plugins prop + * @deprecated Superseded by the tree plugin (useTableTreeData + + * useTableTreeState). Kept for back-compat; new tree tables should use the + * tree plugin. See the migration guide on useTableRowExpansion. + * + * SYNC: When modified, update these files to stay in sync: + * - /packages/core/src/Table/index.ts (exports) + */ + +import {useCallback, useMemo, useRef, type ReactNode} from 'react'; +import * as stylex from '@stylexjs/stylex'; +import {spacingVars, colorVars, radiusVars} from '../../../theme/tokens.stylex'; +import {Icon} from '../../../Icon'; +import {rtlStyles} from '../../../utils'; +import {resolveContextActions} from '../../tableContextMenu'; +import {useTranslator} from '../../../i18n'; +import type { + TablePlugin, + TableColumn, + BodyCellRenderProps, + BodyRowRenderProps, + HeaderCellRenderProps, +} from '../../types'; + +// ============================================================================= +// Config +// ============================================================================= + +/** + * Configuration for useTableRowExpansion (inherited-columns mode). + * + * Child rows use the same columns as their parents, with indentation on the + * first content column. The consumer provides a **flat** data array (use + * {@link useTableRowExpansionState} to flatten a tree) and a `getDepth` + * function so the plugin knows each row's nesting level. + */ +export interface UseTableRowExpansionConfig> { + /** Set of currently-expanded row keys. */ + expandedKeys: Set; + /** Called when a row's expansion is toggled. */ + onToggle: (key: string) => void; + /** Derive a stable unique key from a row item. */ + getRowKey: (item: T) => string; + /** Return the children of a row (used to determine expandability). */ + getChildren: (item: T) => T[]; + /** Return the depth of a row in the hierarchy (0 = top-level). */ + getDepth?: (item: T) => number; + /** Optionally control which rows are expandable. @default checks getChildren length */ + getIsItemExpandable?: (item: T) => boolean; + /** + * When true, clicking anywhere on the row toggles expansion (in addition to + * the chevron button). @default false — only the chevron triggers expansion. + */ + hasRowClickExpansion?: boolean; + /** + * State of the expand-all toggle in the header. `true` = all expanded, + * `false` = all collapsed, `'indeterminate'` = mixed. When provided + * (together with `onToggleExpandAll`), the header cell shows a toggle button. + */ + isAllExpanded?: boolean | 'indeterminate'; + /** Called when the expand-all header toggle is clicked. */ + onToggleExpandAll?: (expand: boolean) => void; +} + +/** + * Configuration for {@link useTableRowExpansionState}. + * + * Mirrors the shape of {@link useTableSelectionState}: you own the + * `expandedKeys` set (via `useState`), and the hook derives everything the + * plugin needs — the flattened `data`, per-row depth, expand/collapse + * handlers, and the expand-all toggle state. + */ +export interface UseTableRowExpansionStateConfig< + T extends Record, +> { + /** The full, un-flattened tree. */ + baseData: T[]; + /** Return the children of a row. Leaf rows return an empty array. */ + getChildren: (item: T) => T[]; + /** Derive a stable unique key from a row item. */ + getRowKey: (item: T) => string; + /** + * Should this row be expandable? Rows that return `false` never show a + * chevron and are skipped by expand-all. @default rows with children + */ + getIsItemExpandable?: (item: T) => boolean; + /** Controlled set of currently-expanded row keys. */ + expandedKeys: Set; + /** Setter for the controlled expanded keys. */ + setExpandedKeys: React.Dispatch>>; +} + +export interface UseTableRowExpansionStateResult< + T extends Record, +> { + /** The flattened, currently-visible rows. Pass to `
`. */ + data: T[]; + /** Ready-to-use config for {@link useTableRowExpansion}. */ + expansionConfig: UseTableRowExpansionConfig; +} + +/** + * Manages row-expansion state and derives the config for + * {@link useTableRowExpansion}. + * + * @deprecated Use `useTableTreeState` (with `useTableTreeData`) instead. The + * tree plugin covers the same affordances (expand-all header control, + * whole-row click) with a cycle guard and per-row fine-grained re-render. See + * the migration guide on `useTableRowExpansion` (`astryx component + * useTableRowExpansion --detail full`) for the before/after and config + * mapping. + * + * @example + * ``` + * const [expandedKeys, setExpandedKeys] = useState>(new Set()); + * const {data, expansionConfig} = useTableRowExpansionState({ + * baseData: tree, + * getChildren: item => item.children ?? [], + * getRowKey: item => item.id, + * expandedKeys, + * setExpandedKeys, + * }); + * const expansion = useTableRowExpansion(expansionConfig); + *
; + * ``` + */ +export function useTableRowExpansionState>({ + baseData, + getChildren, + getRowKey, + getIsItemExpandable, + expandedKeys, + setExpandedKeys, +}: UseTableRowExpansionStateConfig): UseTableRowExpansionStateResult { + const isExpandable = useCallback( + (item: T): boolean => + getIsItemExpandable + ? getIsItemExpandable(item) + : getChildren(item).length > 0, + [getIsItemExpandable, getChildren], + ); + + const depthMap = useMemo(() => { + const map = new Map(); + // Ancestor keys on the current walk path — guards against cyclic data. + const path = new Set(); + function walk(items: T[], depth: number) { + for (const item of items) { + const key = getRowKey(item); + if (path.has(key)) { + continue; // cyclic edge — skip + } + map.set(key, depth); + if (expandedKeys.has(key)) { + path.add(key); + walk(getChildren(item), depth + 1); + path.delete(key); + } + } + } + walk(baseData, 0); + return map; + }, [baseData, getChildren, getRowKey, expandedKeys]); + + const data = useMemo(() => { + const result: T[] = []; + // Ancestor keys on the current walk path — guards against cyclic data. + const path = new Set(); + function walk(items: T[]) { + for (const item of items) { + const key = getRowKey(item); + if (path.has(key)) { + continue; // cyclic edge — skip + } + result.push(item); + if (expandedKeys.has(key)) { + path.add(key); + walk(getChildren(item)); + path.delete(key); + } + } + } + walk(baseData); + return result; + }, [baseData, getChildren, getRowKey, expandedKeys]); + + // Every expandable key across the whole tree (drives expand-all). + const allExpandableKeys = useMemo(() => { + const keys: string[] = []; + // Ancestor keys on the current walk path — guards against cyclic data. + const path = new Set(); + function walk(items: T[]) { + for (const item of items) { + const key = getRowKey(item); + if (path.has(key)) { + continue; // cyclic edge — skip + } + if (isExpandable(item)) { + keys.push(key); + path.add(key); + walk(getChildren(item)); + path.delete(key); + } + } + } + walk(baseData); + return keys; + }, [baseData, getChildren, getRowKey, isExpandable]); + + const getDepth = useCallback( + (item: T) => depthMap.get(getRowKey(item)) ?? 0, + [depthMap, getRowKey], + ); + + const onToggle = useCallback( + (key: string) => { + setExpandedKeys(prev => { + const next = new Set(prev); + if (next.has(key)) { + next.delete(key); + } else { + next.add(key); + } + return next; + }); + }, + [setExpandedKeys], + ); + + const isAllExpanded: boolean | 'indeterminate' = useMemo(() => { + if (allExpandableKeys.length === 0) { + return false; + } + const expandedCount = allExpandableKeys.filter(k => + expandedKeys.has(k), + ).length; + if (expandedCount === 0) { + return false; + } + if (expandedCount === allExpandableKeys.length) { + return true; + } + return 'indeterminate'; + }, [allExpandableKeys, expandedKeys]); + + const onToggleExpandAll = useCallback( + (expand: boolean) => { + setExpandedKeys(expand ? new Set(allExpandableKeys) : new Set()); + }, + [setExpandedKeys, allExpandableKeys], + ); + + const expansionConfig = useMemo( + (): UseTableRowExpansionConfig => ({ + expandedKeys, + onToggle, + getRowKey, + getChildren, + getDepth, + getIsItemExpandable, + isAllExpanded, + onToggleExpandAll, + }), + [ + expandedKeys, + onToggle, + getRowKey, + getChildren, + getDepth, + getIsItemExpandable, + isAllExpanded, + onToggleExpandAll, + ], + ); + + return {data, expansionConfig}; +} + +// ============================================================================= +// Styles +// ============================================================================= + +const EXPANSION_COLUMN_WIDTH = {type: 'pixel' as const, value: 40}; + +/** Indentation applied per depth level, in pixels. */ +const INDENT_PER_DEPTH = 24; + +const expansionStyles = stylex.create({ + chevronButton: { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + width: '24px', + height: '24px', + background: 'transparent', + border: 'none', + borderRadius: radiusVars['--radius-inner'], + cursor: 'pointer', + color: colorVars['--color-icon-secondary'], + transitionProperty: 'transform, color, background-color', + transitionDuration: '150ms', + padding: 0, + flexShrink: '0', + // Match IconButton ghost hover: subtle overlay background + backgroundImage: { + default: null, + ':hover': { + '@media (hover: hover)': `linear-gradient(${colorVars['--color-overlay-hover']}, ${colorVars['--color-overlay-hover']})`, + }, + }, + ':hover': { + color: colorVars['--color-icon-primary'], + }, + }, + chevronExpanded: { + transform: 'rotate(90deg)', + }, + chevronIcon: { + display: 'inline-flex', + transitionProperty: 'transform', + transitionDuration: '150ms', + }, + indentedCell: { + display: 'flex', + alignItems: 'center', + gap: spacingVars['--spacing-1'], + }, + indent: (px: number) => ({ + paddingInlineStart: `${px}px`, + }), + placeholder: { + display: 'inline-block', + width: '24px', + height: '24px', + flexShrink: '0', + }, + clickableRow: { + cursor: 'pointer', + }, +}); + +// ============================================================================= +// Chevron +// ============================================================================= + +function ExpansionChevron({ + isExpanded, + onToggle, + ariaLabel, +}: { + isExpanded: boolean; + onToggle: () => void; + ariaLabel: string; +}) { + return ( + + ); +} + +// ============================================================================= +// Hook +// ============================================================================= + +/** + * Returns a TablePlugin implementing expandable rows with inherited columns. + * + * @deprecated Use `useTableTreeData` (with `useTableTreeState`) instead. The + * tree plugin covers the same affordances (expand-all header control, + * whole-row click) with a cycle guard and per-row fine-grained re-render. See + * the migration guide on this hook's docs (`astryx component + * useTableRowExpansion --detail full`) for the before/after and config + * mapping. + */ +export function useTableRowExpansion>( + config: UseTableRowExpansionConfig, +): TablePlugin { + const t = useTranslator(); + const { + expandedKeys, + onToggle, + getRowKey, + getChildren, + getDepth, + getIsItemExpandable, + hasRowClickExpansion = false, + isAllExpanded, + onToggleExpandAll, + } = config; + + // Track the first non-plugin column key for indentation. + const firstUserColumnKeyRef = useRef(null); + + const expansionColumn = useMemo( + (): TableColumn => ({ + key: '__expansion', + header: '', + width: EXPANSION_COLUMN_WIDTH, + resizable: false, + renderCell: (item: T) => { + // Child rows (depth > 0) show their chevron inline in the first user + // column instead — don't double up here. + const depth = getDepth ? getDepth(item) : 0; + if (depth > 0) { + return null; + } + + const key = getRowKey(item); + const expandable = getIsItemExpandable + ? getIsItemExpandable(item) + : getChildren(item).length > 0; + if (!expandable) { + return null; + } + const isExpanded = expandedKeys.has(key); + return ( + onToggle(key)} + ariaLabel={ + isExpanded + ? t('@astryx.tableRowExpansion.collapseRow') + : t('@astryx.tableRowExpansion.expandRow') + } + /> + ); + }, + }), + [ + expandedKeys, + onToggle, + getRowKey, + getChildren, + getIsItemExpandable, + getDepth, + t, + ], + ); + + return useMemo( + (): TablePlugin => ({ + transformColumns(columns: TableColumn[]) { + // Track the first user column for indentation. + const firstUserCol = columns.find(c => !c.key.startsWith('__')); + firstUserColumnKeyRef.current = firstUserCol?.key ?? null; + + // Wrap the first user column's renderCell to add depth indentation + + // an inline chevron for child rows. This is the inherited-columns + // pattern: child rows use the same columns but indent their first + // content cell. + const wrappedColumns = columns.map(col => { + if (col.key !== firstUserColumnKeyRef.current) { + return col; + } + const originalRenderCell = col.renderCell; + return { + ...col, + renderCell: (item: T): ReactNode => { + const depth = getDepth ? getDepth(item) : 0; + const originalContent = originalRenderCell + ? originalRenderCell(item) + : String( + ((item as Record)[col.key] as + string | number | null | undefined) ?? '', + ); + + if (depth === 0) { + return originalContent; + } + + const indent = (depth - 1) * INDENT_PER_DEPTH; + const key = getRowKey(item); + const isExpanded = expandedKeys.has(key); + const expandable = getIsItemExpandable + ? getIsItemExpandable(item) + : getChildren(item).length > 0; + + const chevron = expandable ? ( + onToggle(key)} + ariaLabel={ + isExpanded + ? t('@astryx.tableRowExpansion.collapseRow') + : t('@astryx.tableRowExpansion.expandRow') + } + /> + ) : ( + + ); + + return ( +
0 && expansionStyles.indent(indent), + )}> + {chevron} + {originalContent} +
+ ); + }, + }; + }); + + return [expansionColumn, ...wrappedColumns]; + }, + + transformHeaderCell( + props: HeaderCellRenderProps, + column: TableColumn, + ): HeaderCellRenderProps { + if (column.key !== '__expansion') { + return props; + } + + // Show expand-all toggle when the consumer provides the state + callback. + if (isAllExpanded !== undefined && onToggleExpandAll) { + const allExpanded = isAllExpanded === true; + return { + ...props, + content: ( + + ), + }; + } + + return {...props, content: null}; + }, + + transformBodyCell( + props: BodyCellRenderProps, + column: TableColumn, + item: T, + ): BodyCellRenderProps { + // Contribute "Expand/Collapse row" context-menu action on every cell + // so right-clicking anywhere in the row shows the option. + const expandable = getIsItemExpandable + ? getIsItemExpandable(item) + : getChildren(item).length > 0; + if (!expandable) { + return props; + } + + const key = getRowKey(item); + const isExpanded = expandedKeys.has(key); + return { + ...props, + contextMenuActions: () => [ + ...resolveContextActions(props.contextMenuActions), + { + id: 'row-expansion-toggle', + group: 'row-expansion', + label: isExpanded + ? t('@astryx.tableRowExpansion.collapseRow') + : t('@astryx.tableRowExpansion.expandRow'), + icon: ( + + + + ), + onSelect: () => onToggle(key), + }, + ], + }; + }, + + transformBodyRow(props: BodyRowRenderProps, item: T): BodyRowRenderProps { + if (!hasRowClickExpansion) { + return props; + } + const expandable = getIsItemExpandable + ? getIsItemExpandable(item) + : getChildren(item).length > 0; + if (!expandable) { + return props; + } + const key = getRowKey(item); + return { + ...props, + htmlProps: { + ...props.htmlProps, + onClick: () => onToggle(key), + }, + xstyle: [...props.xstyle, expansionStyles.clickableRow], + }; + }, + }), + [ + expandedKeys, + getRowKey, + onToggle, + getChildren, + getDepth, + getIsItemExpandable, + hasRowClickExpansion, + isAllExpanded, + onToggleExpandAll, + expansionColumn, + t, + ], + ); +} diff --git a/packages/core/src/Table/useTableGroupedRows.doc.mjs b/packages/core/src/Table/useTableGroupedRows.doc.mjs index 007d6f250cde..0e665402e3c4 100644 --- a/packages/core/src/Table/useTableGroupedRows.doc.mjs +++ b/packages/core/src/Table/useTableGroupedRows.doc.mjs @@ -7,7 +7,7 @@ export const docs = { subComponentOf: 'Table', displayName: 'useTableGroupedRows', description: - 'Hook that groups a flat data array into collapsible section rows. Each distinct groupBy value becomes a full-width section-header row with a chevron toggle, the group label, and a member count; collapsing hides that group\'s data rows while keeping the header visible. The consumer owns the collapsedGroups set and the hook returns {data, plugin, idKey}: pass all three to Table (data, plugins, and idKey respectively).', + 'Hook that groups a flat data array into collapsible section rows. Each distinct groupBy value becomes a full-width section-header row with a chevron toggle, the group label, and a member count; collapsing hides that group\'s data rows while keeping the header visible. Mirrors useTableRowExpansionState: the consumer owns the collapsedGroups set and the hook returns {data, plugin, idKey}: pass all three to Table (data, plugins, and idKey respectively).', props: [ { name: 'data', diff --git a/packages/core/src/Table/useTableRowExpansion.doc.mjs b/packages/core/src/Table/useTableRowExpansion.doc.mjs new file mode 100644 index 000000000000..ffd52033ac1a --- /dev/null +++ b/packages/core/src/Table/useTableRowExpansion.doc.mjs @@ -0,0 +1,133 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** @type {import('@astryxdesign/cli/authoring').ComponentDoc} */ + +export const docs = { + name: 'useTableRowExpansion', + subComponentOf: 'Table', + displayName: 'useTableRowExpansion', + description: + 'Deprecated: use useTableTreeData + useTableTreeState instead. Hook that returns a TablePlugin implementing expandable rows with inherited columns. Child rows use the same columns as their parents, indented by depth. Clicking the chevron (or right-click context menu) toggles expansion. Pair with useTableRowExpansionState, which flattens the tree and derives this config (expand/collapse handlers + expand-all state) from a single expandedKeys set. Converging with useTableTreeData: new tree tables should prefer useTableTreeData + useTableTreeState, which cover the same affordances with a cycle guard and fine-grained re-render. See the migration example below.', + props: [ + { + name: 'expandedKeys', + type: 'Set', + description: 'Set of currently-expanded row keys.', + required: true, + }, + { + name: 'onToggle', + type: '(key: string) => void', + description: 'Called when a row expansion is toggled.', + required: true, + }, + { + name: 'getRowKey', + type: '(item: T) => string', + description: 'Derive a stable unique key from a row item.', + required: true, + }, + { + name: 'getChildren', + type: '(item: T) => T[]', + description: 'Return the children of a row (determines expandability).', + required: true, + }, + { + name: 'getDepth', + type: '(item: T) => number', + description: 'Return the depth of a row in the hierarchy (0 = top-level). Used for indentation.', + }, + { + name: 'getIsItemExpandable', + type: '(item: T) => boolean', + description: 'Control which rows are expandable. Defaults to checking getChildren length.', + }, + { + name: 'hasRowClickExpansion', + type: 'boolean', + description: 'When true, clicking anywhere on the row toggles expansion.', + default: 'false', + }, + { + name: 'isAllExpanded', + type: "boolean | 'indeterminate'", + description: 'State of the expand-all toggle in the header. Enables the header toggle button.', + }, + { + name: 'onToggleExpandAll', + type: '(expand: boolean) => void', + description: 'Callback when the expand-all header toggle is clicked.', + }, + ], + examples: [ + { + label: 'Migrating to useTableTreeData + useTableTreeState', + code: `// useTableRowExpansion and useTableTreeData are converging onto one tree +// plugin. useTableTreeData is the destination: it adds a cycle guard, +// per-row fine-grained re-render, and imperative row ARIA, and now covers +// the same affordances (expand-all header control, whole-row click). + +// BEFORE: useTableRowExpansion + useTableRowExpansionState +const [expandedKeys, setExpandedKeys] = useState(new Set(['root'])); +const {data, expansionConfig} = useTableRowExpansionState({ + baseData: tree, + getChildren: item => item.children ?? [], + getRowKey: item => item.id, + expandedKeys, + setExpandedKeys, +}); +const expansion = useTableRowExpansion(expansionConfig); +
; + +// AFTER: useTableTreeState + useTableTreeData +const {visibleData, treeConfig} = useTableTreeState({ + data: tree, // nested data, not a flat array + idKey: 'id', // or a function: idKey={item => item.id} + childrenKey: 'children', // replaces getChildren (default 'children') + defaultExpandedIds: ['root'], // uncontrolled; or expandedIds + onExpandedIdsChange +}); +const tree = useTableTreeData({ + ...treeConfig, + hasExpandAllControl: true, // was isAllExpanded + onToggleExpandAll + hasRowClickExpansion: true, // same prop name +}); +
;`, + }, + { + label: 'Config mapping', + code: `// useTableRowExpansion(State) -> useTableTreeState / useTableTreeData + +// baseData: T[] (nested) -> data: T[] (useTableTreeState) +// getChildren: item => item.children -> childrenKey: 'children' (property name) +// getRowKey: item => item.id -> idKey: 'id' | (item => item.id) +// getIsItemExpandable -> isItemExpandable (same shape) +// expandedKeys + setExpandedKeys -> defaultExpandedIds (uncontrolled), or +// expandedIds + onExpandedIdsChange (controlled) +// getDepth -> removed; depth derives from nesting +// isAllExpanded + onToggleExpandAll -> hasExpandAllControl (state is computed) +// hasRowClickExpansion -> hasRowClickExpansion (unchanged) + +// Rendering: useTableRowExpansion prepends a dedicated expander column; +// useTableTreeData decorates the tree column in place (configurable via +// treeColumnKey). Keyboard and AT users toggle via the chevron in both.`, + }, + ], +}; + +/** @type {import('@astryxdesign/cli/authoring').ComponentTranslationDoc} */ +export const docsDense = { + description: + 'Deprecated: use useTableTreeData + useTableTreeState instead. Returns a TablePlugin for expandable rows w/ inherited columns. Child rows reuse parent columns, indented by depth. Chevron click (or right-click menu) toggles expansion. Pair w/ useTableRowExpansionState, which flattens the tree + derives this config from one expandedKeys set.', + propDescriptions: { + expandedKeys: 'Set of currently-expanded row keys.', + onToggle: 'Called when a row expansion is toggled.', + getRowKey: 'Derive a stable unique key from a row item.', + getChildren: 'Return children of a row; determines expandability.', + getDepth: 'Return depth of a row (0 = top-level). Used for indentation.', + getIsItemExpandable: 'Control which rows are expandable. Defaults to checking getChildren length.', + hasRowClickExpansion: 'If true, clicking anywhere on the row toggles expansion. Default false.', + isAllExpanded: 'State of the expand-all header toggle. Enables the header toggle button.', + onToggleExpandAll: 'Callback when the expand-all header toggle is clicked.', + }, +};