diff --git a/change/@fluentui-react-button-a17076e1-b6a0-4fb2-9581-410a23ca429d.json b/change/@fluentui-react-button-a17076e1-b6a0-4fb2-9581-410a23ca429d.json new file mode 100644 index 0000000000000..a1f7cfc8e5b1b --- /dev/null +++ b/change/@fluentui-react-button-a17076e1-b6a0-4fb2-9581-410a23ca429d.json @@ -0,0 +1,7 @@ +{ + "type": "none", + "comment": "chore: fix test target dependencies", + "packageName": "@fluentui/react-button", + "email": "martinhochel@microsoft.com", + "dependentChangeType": "none" +} diff --git a/change/@fluentui-react-charts-5b4648df-f96b-4bdd-8347-99216ae29713.json b/change/@fluentui-react-charts-5b4648df-f96b-4bdd-8347-99216ae29713.json new file mode 100644 index 0000000000000..0ce1629461c31 --- /dev/null +++ b/change/@fluentui-react-charts-5b4648df-f96b-4bdd-8347-99216ae29713.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "support chart title from plotly schema", + "packageName": "@fluentui/react-charts", + "email": "anushgupta@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/docs/react-v9/contributing/rfcs/react-components/convergence/base-state-hooks.md b/docs/react-v9/contributing/rfcs/react-components/convergence/base-state-hooks.md new file mode 100644 index 0000000000000..de1bc96fbab69 --- /dev/null +++ b/docs/react-v9/contributing/rfcs/react-components/convergence/base-state-hooks.md @@ -0,0 +1,462 @@ +# RFC: Component Base State Hooks + +## Contributors + +- @dmytrokirpa + +## Summary + +Base state hooks provide pure component logic, accessibility, and structure for Fluent UI v9 components, without any styling opinions. They are an **advanced, opt-in feature** for teams building completely custom components that are not based on Fluent 2 design. + +> **Note:** Most teams should use default components. Use base state hooks only when you need maximum control over rendering and are building a custom design system that significantly diverges from Fluent 2. + +**What they include:** + +- Component behavior, structure, and ARIA patterns +- Keyboard handling +- Semantic slot structure + +**What they exclude:** + +- Design props (appearance, size, shape) +- Style logic (Griffel, design tokens) +- Motion logic (animations, transitions) +- Default slot implementations (icons, components) + +> [!IMPORTANT] +> Base state hooks provide ARIA attributes and semantic structure, but **not visual accessibility** (e.g., focus indicators, sufficient contrast). You're responsible for implementing these in your custom styles. When using base state hooks, you must ensure your custom styles maintain accessibility standards including visible focus indicators, sufficient color contrast, and appropriate visual feedback for all interactive states. + +Base state hooks serve as the foundation layer that styled components are built upon. + +## Problem Statement + +Teams building custom design systems that significantly diverge from Fluent 2 face a specific challenge: they need Fluent's behavior primitives but want complete control over rendering. + +**Who this is for:** + +- Teams with established design systems that are fundamentally different from Fluent 2 +- Organizations building white-label products requiring highly customized components +- Projects needing complete rendering control for specialized use cases + +**Who this is NOT for:** + +- Teams wanting to use Fluent 2 with custom branding (use styled components) +- Teams wanting to customize Fluent's styling (use styled components with custom styles) +- Standard applications using Fluent UI (use styled components) + +Currently, these advanced users must: + +- Import hooks that bundle defaults +- Work with design props they don't need +- Maintain fragile workarounds as Fluent evolves + +**Example:** A team building a completely custom design language with their own component architecture (`variant`, `tone`, `emphasis`, custom rendering) still wants Fluent's accessibility and keyboard handling, but has to bundle some code they never use. + +Base state hooks solve this by providing **only** the base layer. + +## Solution + +Introduce base state hooks per component using the naming pattern `use${ComponentName}Base_unstable`: + +```tsx +// Import from the package directly +import { useButtonBase_unstable } from '@fluentui/react-button'; +``` + +In this RFC, a "base state hook" refers to a `use{Component}Base_unstable` hook that accepts base props and returns base state. + +### Exports and Naming + +**Package exports:** + +- Base state hooks are exported from individual component packages (e.g., `@fluentui/react-button`) +- They are NOT re-exported from the suite package (`@fluentui/react-components`) initially +- Re-exporting from the suite may be considered in the future based on adoption and feedback + +**Naming pattern:** + +- Hook name: `use${ComponentName}Base_unstable` (e.g., `useButtonBase_unstable`) +- Props type: `${ComponentName}BaseProps` (e.g., `ButtonBaseProps`) +- State type: `${ComponentName}BaseState` (e.g., `ButtonBaseState`) + +The `_unstable` suffix is consistent with Fluent UI's convention for lower-level primitives whose implementation details may evolve. + +### Core Principles + +| Principle | Description | Example | +| -------------------- | --------------------------------------------------- | ---------------------------------------------------- | +| **Base only** | Pure component logic, no visual design | ARIA attributes, keyboard handling, focus management | +| **No default slots** | Slots are defined, but nothing is filled by default | Icons, components must be passed by consumer | +| **No styling** | Bare bones "HTML" | No styles, no design tokens, no animation/motion | +| **Base types** | Separate type definitions without design props | `ButtonBaseProps` vs `ButtonProps` | + +## Type Definitions + +Base state hooks use dedicated types that establish a clear hierarchy: + +```tsx +// Base types (component logic only) +export type ButtonBaseProps = ComponentProps & { + disabled?: boolean; + disabledFocusable?: boolean; + iconPosition?: 'before' | 'after'; +}; + +export type ButtonBaseState = ComponentState & { + disabled: boolean; + disabledFocusable: boolean; + iconPosition: 'before' | 'after'; + iconOnly: boolean; +}; + +// Styled component types (add design props) +export type ButtonProps = ButtonBaseProps & { + appearance?: 'primary' | 'secondary' | 'outline' | 'subtle' | 'transparent'; + size?: 'small' | 'medium' | 'large'; + shape?: 'rounded' | 'circular' | 'square'; +}; + +export type ButtonState = ButtonBaseState & Required>; +``` + +Base props/state can still include structure-related layout semantics (for example, `iconPosition`) when they affect slot structure and keyboard/ARIA behavior, not visual design. + +This hierarchy enables: + +- Base state hooks accept `ButtonBaseProps`, return `ButtonBaseState` +- Regular state hooks (existing) accept `ButtonProps`, return `ButtonState` +- Design concerns layer on top of the base + +## Implementation Example + +The Button example is representative; the same layering and composition pattern applies across components. + +### Component State Hook (Before) + +Component state hooks compose base state and design state: + +```tsx +import { useButtonBase_unstable } from './useButtonBase'; +import type { ButtonProps, ButtonState } from './Button.types'; + +export const useButton_unstable = ( + props: ButtonProps, + ref: React.Ref, +): ButtonState => { + const { + // base props + as = 'button', + disabled = false, + disabledFocusable = false, + icon, + iconPosition = 'before', + + // design props + appearance = 'secondary', + size = 'medium', + shape = 'rounded', + } = props; + + // Optional slots only defined if explicitly provided + const iconShorthand = slot.optional(icon, { elementType: 'span' }); + + return { + // base state + disabled, + disabledFocusable, + iconPosition, + iconOnly: Boolean(iconShorthand?.children && !props.children), + root: slot.always>(getIntrinsicElementProps(as, useARIAButtonProps(as, props)), { + elementType: as, + defaultProps: { + ref: ref as React.Ref, + type: as === 'button' ? 'button' : undefined, + }, + }), + icon: iconShorthand, + components: { root: as, icon: 'span' }, + + // design state + appearance, + size, + shape, + }; +}; +``` + +### Component State Hook (After) + +Component state hooks **compose base state hooks and add design state**: + +```tsx +import { useButtonBase_unstable } from './useButtonBase'; +import type { ButtonProps, ButtonState } from './Button.types'; + +export const useButton_unstable = ( + props: ButtonProps, + ref: React.Ref, +): ButtonState => { + const { appearance = 'secondary', size = 'medium', shape = 'rounded' } = props; + + return { + ...useButtonBase_unstable(props, ref), + appearance, + size, + shape, + }; +}; +``` + +### Base State Hook + +The code below is illustrative; some type details may be simplified to keep the example readable. + +```tsx +import * as React from 'react'; +import { type ARIAButtonSlotProps, useARIAButtonProps } from '@fluentui/react-aria'; +import { getIntrinsicElementProps, slot } from '@fluentui/react-utilities'; +import type { ButtonBaseProps, ButtonBaseState } from './Button.types'; + +export const useButtonBase_unstable = ( + props: ButtonBaseProps, + ref: React.Ref, +): ButtonBaseState => { + const { as = 'button', disabled = false, disabledFocusable = false, icon, iconPosition = 'before' } = props; + + // Optional slots only defined if explicitly provided + const iconShorthand = slot.optional(icon, { elementType: 'span' }); + + return { + disabled, + disabledFocusable, + iconPosition, + iconOnly: Boolean(iconShorthand?.children && !props.children), + root: slot.always>(getIntrinsicElementProps(as, useARIAButtonProps(as, props)), { + elementType: as, + defaultProps: { + ref: ref as React.Ref, + type: as === 'button' ? 'button' : undefined, + }, + }), + icon: iconShorthand, + components: { root: as, icon: 'span' }, + }; +}; +``` + +This composition centralizes base while keeping design concerns separate. + +## Usage Example + +Building a custom button with your own design system: + +```tsx +import * as React from 'react'; +import { useButtonBase_unstable, renderButton_unstable } from '@fluentui/react-button'; +import type { ButtonBaseProps, ButtonState } from '@fluentui/react-button'; +import './custom-button.css'; + +type CustomButtonProps = ButtonBaseProps & { + variant?: 'primary' | 'secondary' | 'tertiary'; + tone?: 'neutral' | 'success' | 'warning' | 'danger'; +}; + +export const CustomButton = React.forwardRef( + ({ variant = 'primary', tone = 'neutral', ...props }, ref) => { + const state = useButtonBase_unstable(props, ref); + + // Apply your custom class names, might use 3rd party packages like `classnames` or `clsx` + state.root.className = ['custom-btn', `custom-btn--${variant}`, `custom-btn--${tone}`, state.root.className] + .filter(Boolean) + .join(' '); + + if (state.icon) { + state.icon.className = ['custom-btn__icon', state.icon.className].filter(Boolean).join(' '); + } + + return renderButton_unstable(state as ButtonState); + }, +); +``` + +## Benefits + +| Audience | Benefit | +| ----------------------------------- | ---------------------------------------------------------------------------------------- | +| **Teams building custom libraries** | Pure component logic foundation without visual opinions; complete rendering control | +| **Fluent UI maintainers** | Clean separation of concerns; easier testing; single source of truth for component logic | +| **Design system teams** | Build on proven accessibility without inheriting Fluent's visual design | + +## Testing Strategy + +Test each base state hook for: + +| Category | Verification | +| ----------------- | ------------------------------------------------------- | +| **Structure** | Returns correct state with all required slots | +| **Accessibility** | ARIA attributes, keyboard handling work correctly | +| **Purity** | No Griffel, design tokens, or motion utilities imported | +| **Slots** | No default implementations for optional slots | +| **Refs** | Refs preserved correctly | +| **States** | Disabled/focusable states handled appropriately | + +## Comparison with Styled Variants + +- **Layers**: `use{Component}Base_unstable` (base state hook) → `use{Component}_unstable` (component state hook) → `{Component}` (styled component) +- **Recommended for**: Advanced custom component libraries → Fluent UI internals/customization → most teams +- **Component logic + accessibility**: Present in all three layers +- **Design props**: Only in `use{Component}_unstable` and `{Component}` +- **Default slot implementations**: Only in `use{Component}_unstable` and `{Component}` +- **Style logic / motion logic**: Only in `use{Component}_unstable` and `{Component}` +- **Typical bundle size**: Smallest → medium → largest +- **Control level**: Maximum → high → standard + +## Implementation Plan + +### Phase 1: Pilot ✅ + +- [x] Implement PoC +- [x] Validate with partner teams +- [x] Confirm bundle size improvements + +### Phase 2: Rollout + +| Task | Purpose | +| ------------------------ | ----------------------------------------------------- | +| Document type patterns | Provide guide for `BaseProps` and `BaseState` types | +| Apply to more components | Roll out to Divider, Menu, Tabs, and other components | +| Update documentation | Create usage examples and migration guides | + +### Phase 3: Maintenance + +- Monitor adoption and collect feedback +- Keep base state hooks aligned with accessibility best practices +- Maintain slot structure stability across updates + +## Release Strategy + +Base state hooks will be implemented in main branch (internal only) and released experimentally from a feature branch: + +### Development and Release Process + +| Stage | Approach | +| ---------------------------- | ---------------------------------------------------------------------------------------------------------- | +| **Implementation in main** | Implement base hooks in main branch WITHOUT exporting them publicly (internal only) | +| **Export in feature branch** | Feature branch adds public exports and experimental releases (e.g., `@fluentui/react-button@experimental`) | +| **Partner validation** | Gather feedback and refine API surface based on real-world usage from experimental releases | +| **Stable release** | Merge feature branch to main (only adds exports) and release as stable once approach is validated | + +**Rationale for implementing in main:** + +Implementing base hooks in main (without public exports) allows existing hooks to compose them immediately, reducing the maintenance burden of syncing changes between branches. The feature branch only needs to manage the public API surface (exports), making merge conflicts minimal and allowing main branch development to continue without being blocked by the experimental phase. + +### Experimental Release Benefits + +- Partner teams get early access to test and provide feedback +- API surface can be refined based on actual usage patterns +- Stable releases remain unaffected during validation +- Components can be released incrementally as completed +- Minimal merge conflicts when syncing main to feature branch + +### Stability Guarantees + +Once base state hooks transition from experimental to stable, they follow the same guarantees as other `_unstable` APIs in Fluent UI: + +- **Implementation details** (internal logic, slot structure changes) may change in minor versions +- **Public API surface** (props, state shape, hook signature) will follow semver for breaking changes +- **Accessibility base** (ARIA patterns, keyboard handling) will be maintained and improved without breaking changes when possible +- **Slot structure** will aim for stability, but may evolve with prior communication to minimize impact on consumers + +## FAQ + +### When should I use base state hooks? + +Base state hooks are for advanced use cases only. Use them when: + +- Building a completely custom component library or design system that fundamentally differs from Fluent 2 +- Creating white-label products requiring complete component architecture customization +- Needing full control over component rendering logic and structure + +**For most teams, base state hooks are NOT the right choice.** Instead: + +- **Default case:** Use styled components (`Button`) for standard Fluent UI applications +- **Custom styling needs:** Use styled components with `className` prop, `customStyleHooks_unstable`, or design token overrides +- **Complete customization:** Only use base state hooks (`useButtonBase_unstable`) when you need to control rendering and component structure + +### How do base state hooks relate to styled components? + +Base state hooks are the foundation layer that styled components build upon: + +```mermaid +graph TD + A["useButtonBase_unstable
(base only)
→ ButtonBaseState"] + B["useButton_unstable
(adds design)
→ ButtonState"] + C["Button
(adds styles)
→ Rendered component"] + + A -->|baseState| B + B -->|state| C + + style A fill:#e1f5ff + style B fill:#f3e5f5 + style C fill:#e8f5e9 +``` + +Most teams should use the styled component layer. Base state hooks are only for teams building their own component architecture. + +### Do base state hooks guarantee accessibility? + +Base state hooks provide correct ARIA attributes, keyboard handling, and semantic structure. + +> [!IMPORTANT] +> You're responsible for ensuring custom styles don't interfere with accessibility (e.g., sufficient contrast, visible focus indicators). + +### What about accessibility-critical inline styles? + +Default base state hooks should not apply styles. The exception is when a specific CSS property is required for accessibility or core functionality (and is currently implemented via Griffel styles). In those cases, apply the minimum inline style needed and document the rationale. + +**Examples of accessibility- or functionality-critical styles:** + +- **Visually hidden elements**: Some components have elements present in the DOM but hidden via `visibility: hidden` or positioned outside the viewport for screen reader support +- **Portal positioning**: Components like Portal have base positioning (`position: absolute`, `inset: 0`) that may be required for proper functionality +- **Focus management**: Styles that ensure focus indicators or focus traps work correctly + +**Implementation approach:** + +If inline styles are required, apply the minimum inline style needed in base state hooks on a case-by-case basis. This requires: + +1. **Careful investigation**: Identify which styles are truly required for accessibility vs. visual design +2. **Partner validation**: Confirm with partners that inline styles don't break existing implementations +3. **Documentation**: Clearly document why specific inline styles are included + +> **Note:** This work is incremental per component. Most components should not require inline styles. + +### Why no default slot implementations? + +This gives teams maximum control and avoids bundling code they might not use. If you need default implementations for optional slots (like icons), use styled components instead. + +### Can I mix base state hooks with styled components? + +Yes. You can use base state hooks for some components and styled components for others in the same application. + +> [!NOTE] +> We recommend sticking to a single option in your application to avoid UI misalignments. + +Keep in mind you'll need to provide styles when using base state hooks. + +### Why the `_unstable` suffix? + +The `_unstable` suffix is consistent with Fluent UI's convention for hooks. While base state hooks are meant to be used in production, the suffix indicates they're lower-level primitives whose implementation details may evolve. + +## Out of Scope + +### Render functions + +Base state hooks provide only state and accessibility handling. To build complete components, you'll also need render functions to produce markup. + +We recommend re-using existing render functions (e.g., `renderButton_unstable`) rather than implementing custom render logic. Components that use portals or complex rendering patterns may require additional considerations. Future guidance on render functions will be provided in the Unstyled Components RFC. + +## Future Work + +Base state hooks provide the foundation for additional component variants: + +- **Unstyled components**: Simple wrappers around base state hooks that provide Fluent's component structure without default styling (future proposal). This RFC does not introduce unstyled components; it describes a prerequisite layer. diff --git a/packages/a11y-testing/README.md b/packages/a11y-testing/README.md index 14524c5ea665a..1b6df3bf4c254 100644 --- a/packages/a11y-testing/README.md +++ b/packages/a11y-testing/README.md @@ -1,3 +1,6 @@ # @fluentui/a11y-testing Tests for testing a11y conformance of components and hooks, together with typings for creating custom definitions. + +> [!NOTE] +> This package was developed and used primarily for react-northstar (v0). There is only [one module for the `react-button` package](./src/definitions/react-button/buttonAccessibilityBehaviorDefinition.ts) that is used in its tests. diff --git a/packages/charts/react-charts/library/etc/react-charts.api.md b/packages/charts/react-charts/library/etc/react-charts.api.md index e3832847b5592..687f9bd4042e0 100644 --- a/packages/charts/react-charts/library/etc/react-charts.api.md +++ b/packages/charts/react-charts/library/etc/react-charts.api.md @@ -5,6 +5,7 @@ ```ts import { CurveFactory } from 'd3-shape'; +import type { Font } from '@fluentui/chart-utilities'; import type { JSXElement } from '@fluentui/react-utilities'; import type { Margin } from '@fluentui/chart-utilities'; import { PositioningShorthand } from '@fluentui/react-positioning'; @@ -233,6 +234,7 @@ export interface CartesianChartProps { tickPadding?: number; tickValues?: number[] | Date[] | string[] | undefined; timeFormatLocale?: TimeLocaleDefinition; + titleStyles?: TitleStyles; useUTC?: string | boolean; width?: number; wrapXAxisLables?: boolean; @@ -508,6 +510,7 @@ export const ChartTable: React_2.FunctionComponent; // @public export interface ChartTableProps { + chartTitle?: string; className?: string; componentRef?: React_2.Ref; headers: { @@ -520,6 +523,7 @@ export interface ChartTableProps { style?: React_2.CSSProperties; }[][]; styles?: ChartTableStyles; + titleStyles?: TitleStyles; width?: string | number; } @@ -530,10 +534,14 @@ export interface ChartTableStyles { // (undocumented) chart?: string; // (undocumented) + chartTitle?: string; + // (undocumented) headerCell?: string; // (undocumented) root?: string | React_2.CSSProperties; // (undocumented) + svgTooltip?: string; + // (undocumented) table?: string; } @@ -732,6 +740,7 @@ export interface DonutChartProps extends CartesianChartProps { roundCorners?: boolean; showLabelsInPercent?: boolean; styles?: DonutChartStyles; + titleStyles?: TitleStyles; valueInsideDonut?: string | number; width?: number; } @@ -744,9 +753,11 @@ export interface DonutChartStyleProps extends CartesianChartStyleProps { export interface DonutChartStyles { axisAnnotation?: string; chart?: string; + chartTitle?: string; chartWrapper?: string; legendContainer: string; root?: string; + svgTooltip?: string; } // @public (undocumented) @@ -813,6 +824,7 @@ export interface FunnelChartProps { legendProps?: Partial; orientation?: 'horizontal' | 'vertical'; styles?: FunnelChartStyles; + titleStyles?: TitleStyles; width?: number; } @@ -827,7 +839,9 @@ export interface FunnelChartStyleProps { export interface FunnelChartStyles { calloutContentRoot?: string; chart?: string; + chartTitle?: string; root?: string; + svgTooltip?: string; text?: string; } @@ -895,6 +909,7 @@ export interface GaugeChartProps { segments: GaugeChartSegment[]; styles?: GaugeChartStyles; sublabel?: string; + titleStyles?: TitleStyles; variant?: GaugeChartVariant; width?: number; } @@ -1600,6 +1615,7 @@ export interface SankeyChartProps { enableReflow?: boolean; formatNumberOptions?: Intl.NumberFormatOptions; height?: number; + hideLegend?: boolean; parentRef?: HTMLElement | null; pathColor?: string; reflowProps?: { @@ -1608,6 +1624,7 @@ export interface SankeyChartProps { shouldResize?: number; strings?: SankeyChartStrings; styles?: SankeyChartStyles; + titleStyles?: TitleStyles; width?: number; } @@ -1619,11 +1636,13 @@ export interface SankeyChartStrings { // @public export interface SankeyChartStyles { chart?: string; + chartTitle?: string; chartWrapper?: string; links?: string; nodes?: string; nodeTextContainer?: string; root?: string; + svgTooltip?: string; toolTip?: string; } diff --git a/packages/charts/react-charts/library/src/components/ChartTable/ChartTable.tsx b/packages/charts/react-charts/library/src/components/ChartTable/ChartTable.tsx index c665cd675133c..0bcf13ea559f9 100644 --- a/packages/charts/react-charts/library/src/components/ChartTable/ChartTable.tsx +++ b/packages/charts/react-charts/library/src/components/ChartTable/ChartTable.tsx @@ -7,6 +7,7 @@ import { tokens } from '@fluentui/react-theme'; import * as d3 from 'd3-color'; import { getColorContrast } from '../../utilities/colors'; import { resolveCSSVariables } from '../../utilities/utilities'; +import { ChartTitle } from '../../utilities/index'; import { useImageExport } from '../../utilities/hooks'; import { useArrowNavigationGroup } from '@fluentui/react-tabster'; @@ -47,7 +48,7 @@ function getSafeBackgroundColor(chartContainer: HTMLElement, foreground?: string export const ChartTable: React.FunctionComponent = React.forwardRef( (props, forwardedRef) => { - const { headers, rows, width, height } = props; + const { headers, rows, width, height, chartTitle } = props; const { chartContainerRef: _rootElem } = useImageExport(props.componentRef, true, false); const classes = useChartTableStyles(props); const arrowAttributes = useArrowNavigationGroup({ axis: 'grid' }); @@ -89,16 +90,33 @@ export const ChartTable: React.FunctionComponent = React.forwar } } + const titleHeight = chartTitle ? 30 : 0; + const totalHeight = typeof height === 'number' ? height : 650; + const tableHeight = `${totalHeight - titleHeight}px`; + const svgWidth = typeof width === 'number' ? width : '100%'; + const titleMaxWidth = typeof width === 'number' ? width - 20 : undefined; + const titleX = typeof width === 'number' ? width / 2 : 0; + return (
{ _rootElem.current = el; }} className={classes.root as string} - style={{ height: height ? `${height}px` : '650px', overflow: 'hidden' }} + style={{ height: `${totalHeight}px`, overflow: 'hidden' }} > - - + + {chartTitle && ( + + )} +
= { headerCell: 'fui-ChartTable__headerCell', bodyCell: 'fui-ChartTable__bodyCell', chart: 'fui-ChartTable__chart', + chartTitle: 'fui-ChartTable__chartTitle', + svgTooltip: 'fui-ChartTable__svgTooltip', }; const useStyles = makeStyles({ @@ -47,6 +50,13 @@ const useStyles = makeStyles({ color: 'WindowText', }, }, + chartTitle: getChartTitleStyles() as GriffelStyle, + svgTooltip: { + fill: tokens.colorNeutralBackground1, + [HighContrastSelector]: { + fill: 'Canvas', + }, + }, }); /** @@ -61,5 +71,7 @@ export const useChartTableStyles = (props: ChartTableProps): ChartTableStyles => headerCell: mergeClasses(chartTableClassNames.headerCell, baseStyles.headerCell /*props.styles?.headerCell*/), bodyCell: mergeClasses(chartTableClassNames.bodyCell, baseStyles.bodyCell /*props.styles?.bodyCell*/), chart: mergeClasses(chartTableClassNames.chart /*props.styles?.chart*/), + chartTitle: mergeClasses(chartTableClassNames.chartTitle, baseStyles.chartTitle /*props.styles?.chartTitle*/), + svgTooltip: mergeClasses(chartTableClassNames.svgTooltip, baseStyles.svgTooltip /*props.styles?.svgTooltip*/), }; }; diff --git a/packages/charts/react-charts/library/src/components/CommonComponents/CartesianChart.tsx b/packages/charts/react-charts/library/src/components/CommonComponents/CartesianChart.tsx index 44d5d758d24df..3e32690e29801 100644 --- a/packages/charts/react-charts/library/src/components/CommonComponents/CartesianChart.tsx +++ b/packages/charts/react-charts/library/src/components/CommonComponents/CartesianChart.tsx @@ -25,6 +25,7 @@ import { getSecureProps, DEFAULT_WRAP_WIDTH, autoLayoutXAxisLabels, + getChartTitleInlineStyles, } from '../../utilities/index'; import { useId } from '@fluentui/react-utilities'; import type { JSXElement } from '@fluentui/react-utilities'; @@ -786,10 +787,15 @@ export const CartesianChart: React.FunctionComponent diff --git a/packages/charts/react-charts/library/src/components/CommonComponents/CartesianChart.types.ts b/packages/charts/react-charts/library/src/components/CommonComponents/CartesianChart.types.ts index 454b42fedecc9..0ba28e59de3f1 100644 --- a/packages/charts/react-charts/library/src/components/CommonComponents/CartesianChart.types.ts +++ b/packages/charts/react-charts/library/src/components/CommonComponents/CartesianChart.types.ts @@ -1,6 +1,7 @@ import * as React from 'react'; import type { JSXElement } from '@fluentui/react-utilities'; import { LegendsProps } from '../Legends/index'; +import type { TitleStyles } from '../../utilities/Common.styles'; import { AccessibilityProps, Chart, @@ -176,6 +177,11 @@ export interface CartesianChartStyles { * {@docCategory CartesianChart} */ export interface CartesianChartProps { + /** + * Title styles configuration for the chart title + */ + titleStyles?: TitleStyles; + /** * Below height used for resizing of the chart * Wrap chart in your container and send the updated height and width to these props. diff --git a/packages/charts/react-charts/library/src/components/DeclarativeChart/DeclarativeChart.tsx b/packages/charts/react-charts/library/src/components/DeclarativeChart/DeclarativeChart.tsx index c94d1e40b513c..221aafff0b5ca 100644 --- a/packages/charts/react-charts/library/src/components/DeclarativeChart/DeclarativeChart.tsx +++ b/packages/charts/react-charts/library/src/components/DeclarativeChart/DeclarativeChart.tsx @@ -11,7 +11,7 @@ import { sanitizeJson, } from '@fluentui/chart-utilities'; import type { GridProperties } from './PlotlySchemaAdapter'; -import { tokens } from '@fluentui/react-theme'; +import { tokens, typographyStyles } from '@fluentui/react-theme'; import { ThemeContext_unstable as V9ThemeContext } from '@fluentui/react-shared-contexts'; import { Theme, webLightTheme } from '@fluentui/tokens'; import * as d3Color from 'd3-color'; @@ -40,6 +40,7 @@ import { transformPlotlyJsonToGanttChartProps, transformPlotlyJsonToAnnotationChartProps, } from './PlotlySchemaAdapter'; +import { getChartTitleInlineStyles } from '../../utilities/index'; import type { ColorwayType } from './PlotlyColorAdapter'; import { AnnotationOnlyChart } from '../AnnotationOnlyChart/AnnotationOnlyChart'; import { DonutChart } from '../DonutChart/index'; @@ -537,9 +538,23 @@ export const DeclarativeChart: React.FunctionComponent = ); type ChartType = keyof ChartTypeMap; + + const titleObj = plotlyInputWithValidData.layout?.title; + const chartTitle = typeof titleObj === 'string' ? titleObj : titleObj?.text ?? ''; + const titleFont = typeof titleObj === 'object' ? titleObj?.font : undefined; + + const titleStyle: React.CSSProperties = { + ...typographyStyles.caption1, + color: tokens.colorNeutralForeground1, + textAlign: 'center', + marginBottom: tokens.spacingVerticalS, + ...getChartTitleInlineStyles(titleFont), + }; + // map through the grouped traces and render the appropriate chart return ( <> + {isMultiPlot.current && chartTitle &&
{chartTitle}
}
= ? {} : { ...interactiveCommonProps, - xAxisAnnotation: cellProperties?.xAnnotation, - yAxisAnnotation: cellProperties?.yAnnotation, } ) as Partial>; @@ -592,8 +605,8 @@ export const DeclarativeChart: React.FunctionComponent = [transformedInput, isMultiPlot.current, colorMap, colorwayType, isDarkTheme], { ...resolvedCommonProps, - xAxisAnnotation: cellProperties?.xAnnotation, - yAxisAnnotation: cellProperties?.yAnnotation, + ...(cellProperties?.xAnnotation && { xAxisAnnotation: cellProperties.xAnnotation }), + ...(cellProperties?.yAnnotation && { yAxisAnnotation: cellProperties.yAnnotation }), componentRef: (ref: Chart | null) => { chartRefs.current[chartIdx] = { compRef: ref, diff --git a/packages/charts/react-charts/library/src/components/DeclarativeChart/PlotlySchemaAdapter.ts b/packages/charts/react-charts/library/src/components/DeclarativeChart/PlotlySchemaAdapter.ts index 1422cb36f2396..293a508981ae7 100644 --- a/packages/charts/react-charts/library/src/components/DeclarativeChart/PlotlySchemaAdapter.ts +++ b/packages/charts/react-charts/library/src/components/DeclarativeChart/PlotlySchemaAdapter.ts @@ -102,6 +102,7 @@ import { ChartAnnotationHorizontalAlign, ChartAnnotationVerticalAlign, } from '../../types/ChartAnnotation'; +import type { TitleStyles } from '../../utilities/Common.styles'; export const NON_PLOT_KEY_PREFIX = 'nonplot_'; export const SINGLE_REPEAT = 'repeat(1, 1fr)'; @@ -173,10 +174,26 @@ const dashOptions = { } as const; function getTitles(layout: Partial | undefined) { + const titleObj = layout?.title; + const chartTitle = typeof titleObj === 'string' ? titleObj : titleObj?.text ?? ''; + const titleFont = typeof titleObj === 'object' ? titleObj?.font : undefined; + const titleXAnchor = typeof titleObj === 'object' ? titleObj?.xanchor : undefined; + const titleYAnchor = typeof titleObj === 'object' ? titleObj?.yanchor : undefined; + const titlePad = typeof titleObj === 'object' ? titleObj?.pad : undefined; + + const titleStyles: TitleStyles = { + ...(titleFont ? { titleFont } : {}), + ...(titleXAnchor ? { titleXAnchor } : {}), + ...(titleYAnchor ? { titleYAnchor } : {}), + ...(titlePad ? { titlePad } : {}), + }; + const titles = { - chartTitle: typeof layout?.title === 'string' ? layout.title : layout?.title?.text ?? '', + chartTitle, + ...(Object.keys(titleStyles).length > 0 ? { titleStyles } : {}), xAxisTitle: typeof layout?.xaxis?.title === 'string' ? layout?.xaxis?.title : layout?.xaxis?.title?.text ?? '', yAxisTitle: typeof layout?.yaxis?.title === 'string' ? layout?.yaxis?.title : layout?.yaxis?.title?.text ?? '', + xAxisAnnotation: chartTitle, }; return titles; } @@ -1338,7 +1355,7 @@ export const transformPlotlyJsonToDonutProps = ( const innerRadius: number = firstData.hole ? firstData.hole * (Math.min(width - donutMarginHorizontal, height - donutMarginVertical) / 2) : MIN_DONUT_RADIUS; - const { chartTitle } = getTitles(input.layout); + const { chartTitle, titleStyles } = getTitles(input.layout); // Build anticlockwise order by keeping the first item, reversing the rest const legends = Object.keys(mapLegendToDataPoint); const reorderedEntries = @@ -1367,7 +1384,8 @@ export const transformPlotlyJsonToDonutProps = ( : true, roundCorners: true, order: 'sorted', - }; + ...titleStyles, + } as DonutChartProps; }; export const transformPlotlyJsonToVSBCProps = ( @@ -2681,7 +2699,7 @@ export const transformPlotlyJsonToSankeyProps = ( // }, // }; - const { chartTitle } = getTitles(input.layout); + const { chartTitle, titleStyles } = getTitles(input.layout); return { data: { @@ -2692,7 +2710,9 @@ export const transformPlotlyJsonToSankeyProps = ( height: input.layout?.height ?? 468, // TODO // styles, - }; + hideLegend: isMultiPlot || input.layout?.showlegend === false, + ...titleStyles, + } as SankeyChartProps; }; export const transformPlotlyJsonToGaugeProps = ( @@ -2796,7 +2816,7 @@ export const transformPlotlyJsonToGaugeProps = ( sublabel: sublabelColor, }; - const { chartTitle } = getTitles(input.layout); + const { chartTitle, titleStyles } = getTitles(input.layout); return { segments, @@ -2814,7 +2834,8 @@ export const transformPlotlyJsonToGaugeProps = ( variant: firstData.gauge?.steps?.length ? 'multiple-segments' : 'single-segment', styles, roundCorners: true, - }; + ...titleStyles, + } as GaugeChartProps; }; const cleanText = (text: string): string => { @@ -2982,13 +3003,17 @@ export const transformPlotlyJsonToChartTableProps = ( values: tableHeader?.values ?? templateHeader?.values ?? [], }; + const { chartTitle, titleStyles } = getTitles(input.layout); + return { headers: normalizeHeaders(tableData.header?.values ?? [], header), rows, width: input.layout?.width, height: input.layout?.height, styles, - }; + chartTitle, + ...titleStyles, + } as ChartTableProps; }; function getCategoriesAndValues(series: Partial): { @@ -3137,14 +3162,17 @@ export const transformPlotlyJsonToFunnelChartProps = ( }); }); } + const { chartTitle, titleStyles } = getTitles(input.layout); return { data: funnelData, + chartTitle, width: input.layout?.width, height: input.layout?.height, orientation: (input.data[0] as Partial)?.orientation === 'v' ? 'horizontal' : 'vertical', hideLegend: isMultiPlot || input.layout?.showlegend === false, - }; + ...titleStyles, + } as FunnelChartProps; }; export const projectPolarToCartesian = (input: PlotlySchema): PlotlySchema => { diff --git a/packages/charts/react-charts/library/src/components/DeclarativeChart/__snapshots__/PlotlySchemaAdapterUT.test.tsx.snap b/packages/charts/react-charts/library/src/components/DeclarativeChart/__snapshots__/PlotlySchemaAdapterUT.test.tsx.snap index 050ac834fbb0b..d7ee0661ff774 100644 --- a/packages/charts/react-charts/library/src/components/DeclarativeChart/__snapshots__/PlotlySchemaAdapterUT.test.tsx.snap +++ b/packages/charts/react-charts/library/src/components/DeclarativeChart/__snapshots__/PlotlySchemaAdapterUT.test.tsx.snap @@ -338,6 +338,7 @@ Object { "showRoundOffXTickValues": false, "showYAxisLables": true, "width": 850, + "xAxisAnnotation": "PHP Framework Popularity at Work - SitePoint, 2015", "xAxisCategoryOrder": Array [ "Jan", "Feb", @@ -2844,6 +2845,7 @@ Object { "xAxis": Object { "tickLayout": "auto", }, + "xAxisAnnotation": "", "xAxisCategoryOrder": Array [ "x_0", "x_1", @@ -3048,6 +3050,7 @@ Object { "showYAxisLables": true, "showYAxisLablesTooltip": true, "width": 850, + "xAxisAnnotation": "PHP Framework Popularity at Work - SitePoint, 2015", "xAxisCategoryOrder": "data", "xAxisTitle": "Votes", "xMaxValue": 1830.6731869091736, @@ -4634,6 +4637,7 @@ Object { "supportNegativeData": true, "useUTC": false, "width": undefined, + "xAxisAnnotation": "", "xAxisTitle": "", "yAxisTitle": "", "yMaxValue": 16.562957844203055, @@ -4783,6 +4787,7 @@ Object { "chartTitle": "Scottish Referendum Voters who now want Independence", }, "height": 772, + "hideLegend": false, "width": undefined, } `; @@ -5052,6 +5057,7 @@ Object { "supportNegativeData": true, "useUTC": false, "width": undefined, + "xAxisAnnotation": "", "xAxisTitle": "", "yAxisTitle": "", } @@ -5200,6 +5206,7 @@ Object { "supportNegativeData": true, "useUTC": false, "width": undefined, + "xAxisAnnotation": "", "xAxisCategoryOrder": "data", "xAxisTitle": "", "yAxisCategoryOrder": "data", @@ -5488,7 +5495,13 @@ Object { "roundCorners": true, "roundedTicks": true, "showYAxisLables": true, + "titleStyles": Object { + "titleFont": Object { + "color": "#4D5663", + }, + }, "width": undefined, + "xAxisAnnotation": "", "xAxisCategoryOrder": "data", "xAxisTitle": "", "yAxisCategoryOrder": "data", @@ -5607,6 +5620,7 @@ Object { "xAxis": Object { "tickLayout": "auto", }, + "xAxisAnnotation": "", "xAxisCategoryOrder": Array [ "Jan", "Feb", diff --git a/packages/charts/react-charts/library/src/components/DonutChart/DonutChart.tsx b/packages/charts/react-charts/library/src/components/DonutChart/DonutChart.tsx index 63080de4d7537..863b996569ab1 100644 --- a/packages/charts/react-charts/library/src/components/DonutChart/DonutChart.tsx +++ b/packages/charts/react-charts/library/src/components/DonutChart/DonutChart.tsx @@ -7,7 +7,14 @@ import { DonutChartProps } from './DonutChart.types'; import { useDonutChartStyles } from './useDonutChartStyles.styles'; import { ChartDataPoint } from '../../DonutChart'; import { formatToLocaleString } from '@fluentui/chart-utilities'; -import { areArraysEqual, getColorFromToken, getNextColor, MIN_DONUT_RADIUS } from '../../utilities/index'; +import { + areArraysEqual, + getColorFromToken, + getNextColor, + MIN_DONUT_RADIUS, + ChartTitle, + CHART_TITLE_PADDING, +} from '../../utilities/index'; import { Legend, Legends } from '../../index'; import { useId } from '@fluentui/react-utilities'; import type { JSXElement } from '@fluentui/react-utilities'; @@ -304,7 +311,14 @@ export const DonutChart: React.FunctionComponent = React.forwar const legendBars = _createLegends(points.filter(d => d.data! >= 0)); const donutMarginHorizontal = props.hideLabels ? 0 : 80; const donutMarginVertical = props.hideLabels ? 0 : 40; - const outerRadius = Math.min(_width! - donutMarginHorizontal, _height! - donutMarginVertical) / 2; + const titleHeight = data?.chartTitle + ? Math.max( + (typeof props.titleStyles?.titleFont?.size === 'number' ? props.titleStyles.titleFont.size : 13) + + CHART_TITLE_PADDING, + 36, + ) + : 0; + const outerRadius = Math.min(_width! - donutMarginHorizontal, _height! - donutMarginVertical - titleHeight) / 2; const chartData = _elevateToMinimums(points); const valueInsideDonut = props.innerRadius! > MIN_DONUT_RADIUS ? _valueInsideDonut(props.valueInsideDonut!, chartData!) : ''; @@ -324,6 +338,16 @@ export const DonutChart: React.FunctionComponent = React.forwar )}
+ {!hideLegend && data?.chartTitle && ( + + )} + + +
+ + +
+ + +
+
+ , "container":
+ + +
+ + +
+
+ , "container":
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
= { legendContainer: 'fui-donut__legendContainer', chartWrapper: 'fui-donut__chartWrapper', axisAnnotation: 'fui-donut__axisAnnotation', + chartTitle: 'fui-donut__chartTitle', + svgTooltip: 'fui-donut__svgTooltip', }; /** @@ -41,6 +43,13 @@ const useStyles = makeStyles({ width: '100%', }, axisAnnotation: getAxisTitleStyle() as GriffelStyle, + chartTitle: getChartTitleStyles() as GriffelStyle, + svgTooltip: { + fill: tokens.colorNeutralBackground1, + [HighContrastSelector]: { + fill: 'Canvas', + }, + }, }); /** @@ -64,5 +73,7 @@ export const useDonutChartStyles = (props: DonutChartProps): DonutChartStyles => baseStyles.axisAnnotation, props.styles?.axisAnnotation, ), + chartTitle: mergeClasses(donutClassNames.chartTitle, baseStyles.chartTitle, props.styles?.chartTitle), + svgTooltip: mergeClasses(donutClassNames.svgTooltip, baseStyles.svgTooltip, props.styles?.svgTooltip), }; }; diff --git a/packages/charts/react-charts/library/src/components/FunnelChart/FunnelChart.tsx b/packages/charts/react-charts/library/src/components/FunnelChart/FunnelChart.tsx index bda043be00ac8..6e3d4a34acf71 100644 --- a/packages/charts/react-charts/library/src/components/FunnelChart/FunnelChart.tsx +++ b/packages/charts/react-charts/library/src/components/FunnelChart/FunnelChart.tsx @@ -3,7 +3,7 @@ import * as React from 'react'; import { useId } from '@fluentui/react-utilities'; import type { JSXElement } from '@fluentui/react-utilities'; -import { useRtl } from '../../utilities/index'; +import { useRtl, ChartTitle, CHART_TITLE_PADDING } from '../../utilities/index'; import { FunnelChartDataPoint, FunnelChartProps } from './FunnelChart.types'; import { Legend, Legends } from '../Legends/index'; import { useArrowNavigationGroup } from '@fluentui/react-tabster'; @@ -457,14 +457,38 @@ export const FunnelChart: React.FunctionComponent = React.forw const width = props.width || 350; const height = props.height || 500; - const funnelMarginTop = 40; + const titleHeight = props.chartTitle + ? Math.max( + (typeof props.titleStyles?.titleFont?.size === 'number' ? props.titleStyles.titleFont.size : 13) + + CHART_TITLE_PADDING, + 40, + ) + : 40; + const funnelMarginTop = titleHeight; const funnelWidth = width * 0.8; const funnelOffsetX = (width - funnelWidth) / 2; const arrowAttributes = useArrowNavigationGroup({ circular: true, axis: 'horizontal' }); return !_isChartEmpty() ? (
- + + {!props.hideLegend && props.chartTitle && ( + + )} = { chart: 'fui-funnel__chart', text: 'fui-funnel__text', calloutContentRoot: 'fui-funnel__callout-content-root', + chartTitle: 'fui-funnel__chartTitle', + svgTooltip: 'fui-funnel__svgTooltip', }; /** @@ -44,6 +47,13 @@ const useStyles = makeStyles({ calloutContentRoot: { maxWidth: '238px', }, + chartTitle: getChartTitleStyles() as GriffelStyle, + svgTooltip: { + fill: tokens.colorNeutralBackground1, + [HighContrastSelector]: { + fill: 'Canvas', + }, + }, }); /** @@ -58,5 +68,7 @@ export const useFunnelChartStyles = (props: FunnelChartProps): FunnelChartStyles chart: mergeClasses(funnelClassNames.chart, baseStyles.chart, props.styles?.chart), text: mergeClasses(funnelClassNames.text, baseStyles.text, props.styles?.text), calloutContentRoot: mergeClasses(baseStyles.calloutContentRoot, props.styles?.calloutContentRoot), + chartTitle: mergeClasses(funnelClassNames.chartTitle, baseStyles.chartTitle, props.styles?.chartTitle), + svgTooltip: mergeClasses(funnelClassNames.svgTooltip, baseStyles.svgTooltip, props.styles?.svgTooltip), }; }; diff --git a/packages/charts/react-charts/library/src/components/GaugeChart/GaugeChart.tsx b/packages/charts/react-charts/library/src/components/GaugeChart/GaugeChart.tsx index 8fbf0478a3b80..6ffea543488bd 100644 --- a/packages/charts/react-charts/library/src/components/GaugeChart/GaugeChart.tsx +++ b/packages/charts/react-charts/library/src/components/GaugeChart/GaugeChart.tsx @@ -14,6 +14,7 @@ import { getNextColor, pointTypes, useRtl, + ChartTitle, } from '../../utilities/index'; import { formatToLocaleString } from '@fluentui/chart-utilities'; import { SVGTooltipText } from '../../utilities/SVGTooltipText'; @@ -598,15 +599,13 @@ export const GaugeChart: React.FunctionComponent = React.forwar > {props.chartTitle && ( - - {props.chartTitle} - + titleStyles={props.titleStyles} + /> )} {!props.hideMinMax && ( <> diff --git a/packages/charts/react-charts/library/src/components/GaugeChart/GaugeChart.types.ts b/packages/charts/react-charts/library/src/components/GaugeChart/GaugeChart.types.ts index 4d67386fbdd2e..dea6016c817c2 100644 --- a/packages/charts/react-charts/library/src/components/GaugeChart/GaugeChart.types.ts +++ b/packages/charts/react-charts/library/src/components/GaugeChart/GaugeChart.types.ts @@ -1,6 +1,7 @@ import { LegendsProps } from '../Legends/index'; import { AccessibilityProps, Chart } from '../../types/index'; import { ChartPopoverProps } from '../CommonComponents/ChartPopover.types'; +import type { TitleStyles } from '../../utilities/Common.styles'; /** * Gauge Chart segment interface. @@ -48,6 +49,11 @@ export type GaugeChartVariant = 'single-segment' | 'multiple-segments'; * {@docCategory GaugeChart} */ export interface GaugeChartProps { + /** + * Title styles configuration for the chart title + */ + titleStyles?: TitleStyles; + /** * Width of the chart */ diff --git a/packages/charts/react-charts/library/src/components/GaugeChart/__snapshots__/GaugeChart.test.tsx.snap b/packages/charts/react-charts/library/src/components/GaugeChart/__snapshots__/GaugeChart.test.tsx.snap index 29aa749763656..6b75168460293 100644 --- a/packages/charts/react-charts/library/src/components/GaugeChart/__snapshots__/GaugeChart.test.tsx.snap +++ b/packages/charts/react-charts/library/src/components/GaugeChart/__snapshots__/GaugeChart.test.tsx.snap @@ -3,7 +3,7 @@ exports[`Gauge Chart - Theme Change Should reflect theme change 1`] = `