From cb2ac11b1bdb9d04fc2c5276f3f56c6e8ee4d9ba Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Fri, 20 Jun 2025 14:42:27 +0100 Subject: [PATCH 1/8] Create first version of useStyles hook Signed-off-by: Charles de Dreuille --- .../canon/src/components/Button/Button.tsx | 13 +- packages/canon/src/hooks/useStyles.ts | 87 ++++++ packages/canon/src/index.ts | 3 + packages/canon/src/types.ts | 35 +++ .../canon/src/utils/componentDefinitions.ts | 281 ++++++++++++++++++ 5 files changed, 413 insertions(+), 6 deletions(-) create mode 100644 packages/canon/src/hooks/useStyles.ts create mode 100644 packages/canon/src/utils/componentDefinitions.ts diff --git a/packages/canon/src/components/Button/Button.tsx b/packages/canon/src/components/Button/Button.tsx index 9d256a5815..29593ea922 100644 --- a/packages/canon/src/components/Button/Button.tsx +++ b/packages/canon/src/components/Button/Button.tsx @@ -17,8 +17,8 @@ import clsx from 'clsx'; import { forwardRef, Ref } from 'react'; import { Button as RAButton } from 'react-aria-components'; -import { useResponsiveValue } from '../../hooks/useResponsiveValue'; import type { ButtonProps } from './types'; +import { useStyles } from '../../definition'; /** @public */ export const Button = forwardRef( @@ -33,15 +33,16 @@ export const Button = forwardRef( ...rest } = props; - const responsiveSize = useResponsiveValue(size); - const responsiveVariant = useResponsiveValue(variant); + const { classNames, dataAttributes } = useStyles('Button', { + size, + variant, + }); return ( {iconStart} diff --git a/packages/canon/src/hooks/useStyles.ts b/packages/canon/src/hooks/useStyles.ts new file mode 100644 index 0000000000..d6c9d0c76b --- /dev/null +++ b/packages/canon/src/hooks/useStyles.ts @@ -0,0 +1,87 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { useBreakpoint, breakpoints } from './useBreakpoint'; +import { componentDefinitions } from '../utils/componentDefinitions'; +import type { ComponentDefinitionName } from '../types'; + +/** + * Resolve a responsive value based on the current breakpoint + * @param value - The responsive value (string or object with breakpoint keys) + * @param breakpoint - The current breakpoint + * @returns The resolved value for the current breakpoint + */ +function resolveResponsiveValue( + value: string | Record, + breakpoint: string, +): string | undefined { + if (typeof value === 'string') { + return value; + } + + if (typeof value === 'object' && value !== null) { + const index = breakpoints.findIndex(b => b.id === breakpoint); + + // Look for value at current breakpoint or smaller + for (let i = index; i >= 0; i--) { + if (value[breakpoints[i].id]) { + return value[breakpoints[i].id]; + } + } + + // If no value found, check from smallest breakpoint up + for (let i = 0; i < breakpoints.length; i++) { + if (value[breakpoints[i].id]) { + return value[breakpoints[i].id]; + } + } + } + + return undefined; +} + +/** + * React hook to get class names and data attributes for a component with responsive support + * @param componentName - The name of the component + * @param props - Object with prop values (can be responsive) + * @returns Object with classNames and dataAttributes + */ +export function useStyles( + componentName: T, + props: Record = {}, +) { + const { breakpoint } = useBreakpoint(); + const classNames = componentDefinitions[componentName].classNames; + + // Resolve responsive values and generate data attributes + const dataAttributes: Record = {}; + const resolvedProps: Record = {}; + + for (const [key, value] of Object.entries(props)) { + if (value !== undefined && value !== null) { + const resolvedValue = resolveResponsiveValue(value, breakpoint); + if (resolvedValue !== undefined) { + resolvedProps[key] = resolvedValue; + dataAttributes[`data-${key}`] = resolvedValue; + } + } + } + + return { + classNames, + dataAttributes, + resolvedProps, // Also return resolved props for convenience + }; +} diff --git a/packages/canon/src/index.ts b/packages/canon/src/index.ts index d35883bbe6..4da616638c 100644 --- a/packages/canon/src/index.ts +++ b/packages/canon/src/index.ts @@ -57,3 +57,6 @@ export * from './props'; // Hooks export { useBreakpoint } from './hooks/useBreakpoint'; + +// Component Definitions +export * from './utils/componentDefinitions'; diff --git a/packages/canon/src/types.ts b/packages/canon/src/types.ts index ee5d954576..056d4772ba 100644 --- a/packages/canon/src/types.ts +++ b/packages/canon/src/types.ts @@ -14,6 +14,8 @@ * limitations under the License. */ +import { componentDefinitions } from './utils/componentDefinitions'; + /** @public */ export type AsProps = | 'div' @@ -131,3 +133,36 @@ export interface UtilityProps extends SpaceProps { justifyContent?: Responsive; rowSpan?: Responsive; } + +// Base types for the component styles structure +type ClassNamesMap = Record; +type DataAttributeValues = readonly (string | number | boolean)[]; +type DataAttributesMap = Record; + +export interface ComponentDefinition { + classNames: ClassNamesMap; + dataAttributes?: DataAttributesMap; +} + +// Type utilities for extracting information from the component styles +export type ComponentDefinitionName = keyof typeof componentDefinitions; + +export type ComponentClassNames = + (typeof componentDefinitions)[T]['classNames']; + +export type ComponentDataAttributes = + (typeof componentDefinitions)[T] extends { dataAttributes: infer DA } + ? DA + : Record; + +export type ComponentDataAttributeProps = { + [K in keyof ComponentDataAttributes]?: ComponentDataAttributes[K] extends readonly (infer U)[] + ? U + : never; +}; + +// Helper type to check if a component has data attributes +export type HasDataAttributes = + (typeof componentDefinitions)[T] extends { dataAttributes: any } + ? true + : false; diff --git a/packages/canon/src/utils/componentDefinitions.ts b/packages/canon/src/utils/componentDefinitions.ts new file mode 100644 index 0000000000..9d6926a002 --- /dev/null +++ b/packages/canon/src/utils/componentDefinitions.ts @@ -0,0 +1,281 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { ComponentDefinition } from '../types'; + +export const componentDefinitions = { + // Layout Components + Box: { + classNames: { + root: 'canon-Box', + }, + }, + + Container: { + classNames: { + root: 'canon-Container', + }, + }, + + Flex: { + classNames: { + root: 'canon-Flex', + }, + }, + + Grid: { + classNames: { + root: 'canon-Grid', + }, + }, + + // Typography Components + Text: { + classNames: { + root: 'canon-Text', + }, + dataAttributes: { + variant: ['body', 'caption', 'label'] as const, + weight: ['regular', 'medium', 'bold'] as const, + color: ['primary', 'secondary', 'muted'] as const, + truncate: [true, false] as const, + }, + }, + + Heading: { + classNames: { + root: 'canon-Heading', + }, + dataAttributes: { + variant: ['title1', 'title2', 'title3', 'subtitle'] as const, + color: ['primary', 'secondary', 'muted'] as const, + truncate: [true, false] as const, + }, + }, + + // Form Components + Button: { + classNames: { + root: 'canon-Button', + }, + dataAttributes: { + size: ['small', 'medium', 'large'] as const, + variant: ['primary', 'secondary', 'ghost'] as const, + }, + }, + + ButtonIcon: { + classNames: { + root: 'canon-ButtonIcon', + }, + dataAttributes: { + size: ['small', 'medium', 'large'] as const, + variant: ['primary', 'secondary', 'ghost'] as const, + }, + }, + + ButtonLink: { + classNames: { + root: 'canon-ButtonLink', + }, + }, + + TextField: { + classNames: { + root: 'canon-TextField', + input: 'canon-TextField-input', + label: 'canon-TextField-label', + error: 'canon-TextField-error', + helper: 'canon-TextField-helper', + container: 'canon-TextField-container', + }, + dataAttributes: { + invalid: [true, false] as const, + disabled: [true, false] as const, + }, + }, + + FieldLabel: { + classNames: { + root: 'canon-FieldLabel', + required: 'canon-FieldLabel-required', + }, + dataAttributes: { + required: [true, false] as const, + }, + }, + + Select: { + classNames: { + root: 'canon-Select', + trigger: 'canon-Select-trigger', + content: 'canon-Select-content', + item: 'canon-Select-item', + value: 'canon-Select-value', + icon: 'canon-Select-icon', + separator: 'canon-Select-separator', + }, + dataAttributes: { + invalid: [true, false] as const, + disabled: [true, false] as const, + open: [true, false] as const, + }, + }, + + Checkbox: { + classNames: { + root: 'canon-Checkbox', + input: 'canon-Checkbox-input', + indicator: 'canon-Checkbox-indicator', + label: 'canon-Checkbox-label', + }, + dataAttributes: { + checked: [true, false] as const, + indeterminate: [true, false] as const, + disabled: [true, false] as const, + }, + }, + + Switch: { + classNames: { + root: 'canon-Switch', + thumb: 'canon-Switch-thumb', + track: 'canon-Switch-track', + }, + dataAttributes: { + checked: [true, false] as const, + disabled: [true, false] as const, + }, + }, + + // Navigation Components + Link: { + classNames: { + root: 'canon-Link', + }, + dataAttributes: { + variant: ['primary', 'secondary'] as const, + }, + }, + + Menu: { + classNames: { + root: 'canon-Menu', + trigger: 'canon-Menu-trigger', + content: 'canon-Menu-content', + item: 'canon-Menu-item', + separator: 'canon-Menu-separator', + label: 'canon-Menu-label', + group: 'canon-Menu-group', + }, + }, + + Tabs: { + classNames: { + root: 'canon-Tabs', + list: 'canon-Tabs-list', + tab: 'canon-Tabs-tab', + panel: 'canon-Tabs-panel', + trigger: 'canon-Tabs-trigger', + }, + }, + + // Data Display Components + Table: { + classNames: { + root: 'canon-Table', + header: 'canon-Table-header', + body: 'canon-Table-body', + footer: 'canon-Table-footer', + row: 'canon-Table-row', + cell: 'canon-Table-cell', + headerCell: 'canon-Table-headerCell', + caption: 'canon-Table-caption', + }, + }, + + DataTable: { + classNames: { + root: 'canon-DataTable', + container: 'canon-DataTable-container', + header: 'canon-DataTable-header', + body: 'canon-DataTable-body', + row: 'canon-DataTable-row', + cell: 'canon-DataTable-cell', + headerCell: 'canon-DataTable-headerCell', + toolbar: 'canon-DataTable-toolbar', + pagination: 'canon-DataTable-pagination', + search: 'canon-DataTable-search', + filters: 'canon-DataTable-filters', + }, + }, + + Avatar: { + classNames: { + root: 'canon-Avatar', + image: 'canon-Avatar-image', + fallback: 'canon-Avatar-fallback', + indicator: 'canon-Avatar-indicator', + }, + dataAttributes: { + size: ['small', 'medium', 'large'] as const, + }, + }, + + Icon: { + classNames: { + root: 'canon-Icon', + }, + dataAttributes: { + size: ['small', 'medium', 'large'] as const, + }, + }, + + // Feedback Components + Tooltip: { + classNames: { + root: 'canon-Tooltip', + trigger: 'canon-Tooltip-trigger', + content: 'canon-Tooltip-content', + arrow: 'canon-Tooltip-arrow', + }, + }, + + // Disclosure Components + Collapsible: { + classNames: { + root: 'canon-Collapsible', + trigger: 'canon-Collapsible-trigger', + content: 'canon-Collapsible-content', + icon: 'canon-Collapsible-icon', + }, + dataAttributes: { + open: [true, false] as const, + }, + }, + + // Utility Components + ScrollArea: { + classNames: { + root: 'canon-ScrollArea', + viewport: 'canon-ScrollArea-viewport', + scrollbar: 'canon-ScrollArea-scrollbar', + thumb: 'canon-ScrollArea-thumb', + corner: 'canon-ScrollArea-corner', + track: 'canon-ScrollArea-track', + }, + }, +} as const satisfies Record; From e2d70b7f5e391c6c839a5301f4f31acb51e94e5e Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Fri, 20 Jun 2025 14:58:52 +0100 Subject: [PATCH 2/8] replace buttons Signed-off-by: Charles de Dreuille --- .../canon/src/components/Avatar/Avatar.tsx | 44 +++++++----- .../canon/src/components/Button/Button.tsx | 2 +- .../src/components/ButtonIcon/ButtonIcon.tsx | 15 ++-- .../src/components/ButtonLink/ButtonLink.tsx | 15 ++-- packages/canon/src/hooks/useStyles.ts | 11 ++- .../canon/src/utils/componentDefinitions.ts | 69 ++++++++----------- 6 files changed, 82 insertions(+), 74 deletions(-) diff --git a/packages/canon/src/components/Avatar/Avatar.tsx b/packages/canon/src/components/Avatar/Avatar.tsx index 472bec39fb..4e899170de 100644 --- a/packages/canon/src/components/Avatar/Avatar.tsx +++ b/packages/canon/src/components/Avatar/Avatar.tsx @@ -18,28 +18,36 @@ import { forwardRef, ElementRef } from 'react'; import { Avatar as AvatarPrimitive } from '@base-ui-components/react/avatar'; import clsx from 'clsx'; import { AvatarProps } from './types'; +import { useStyles } from '../../hooks/useStyles'; /** @public */ export const Avatar = forwardRef< ElementRef, AvatarProps ->(({ className, src, name, size = 'medium', ...props }, ref) => ( - - - - {(name || '') - .split(' ') - .map(word => word[0]) - .join('') - .toLocaleUpperCase('en-US') - .slice(0, 2)} - - -)); +>((props, ref) => { + const { className, src, name, size = 'medium', ...rest } = props; + const { classNames } = useStyles('Avatar', { + size, + }); + + return ( + + + + {(name || '') + .split(' ') + .map(word => word[0]) + .join('') + .toLocaleUpperCase('en-US') + .slice(0, 2)} + + + ); +}); Avatar.displayName = AvatarPrimitive.Root.displayName; diff --git a/packages/canon/src/components/Button/Button.tsx b/packages/canon/src/components/Button/Button.tsx index 29593ea922..40879e51ed 100644 --- a/packages/canon/src/components/Button/Button.tsx +++ b/packages/canon/src/components/Button/Button.tsx @@ -18,7 +18,7 @@ import clsx from 'clsx'; import { forwardRef, Ref } from 'react'; import { Button as RAButton } from 'react-aria-components'; import type { ButtonProps } from './types'; -import { useStyles } from '../../definition'; +import { useStyles } from '../../hooks/useStyles'; /** @public */ export const Button = forwardRef( diff --git a/packages/canon/src/components/ButtonIcon/ButtonIcon.tsx b/packages/canon/src/components/ButtonIcon/ButtonIcon.tsx index 43c2f598ac..072ed20963 100644 --- a/packages/canon/src/components/ButtonIcon/ButtonIcon.tsx +++ b/packages/canon/src/components/ButtonIcon/ButtonIcon.tsx @@ -17,8 +17,8 @@ import clsx from 'clsx'; import { forwardRef, Ref } from 'react'; import { Button as RAButton } from 'react-aria-components'; -import { useResponsiveValue } from '../../hooks/useResponsiveValue'; import type { ButtonIconProps } from './types'; +import { useStyles } from '../../hooks/useStyles'; /** @public */ export const ButtonIcon = forwardRef( @@ -32,15 +32,18 @@ export const ButtonIcon = forwardRef( ...rest } = props; - const responsiveSize = useResponsiveValue(size); - const responsiveVariant = useResponsiveValue(variant); + const { classNames, dataAttributes } = useStyles('Button', { + size, + variant, + }); + + const { classNames: classNamesButtonIcon } = useStyles('ButtonIcon'); return ( {icon} diff --git a/packages/canon/src/components/ButtonLink/ButtonLink.tsx b/packages/canon/src/components/ButtonLink/ButtonLink.tsx index cb68401160..153643a530 100644 --- a/packages/canon/src/components/ButtonLink/ButtonLink.tsx +++ b/packages/canon/src/components/ButtonLink/ButtonLink.tsx @@ -17,8 +17,8 @@ import clsx from 'clsx'; import { forwardRef, Ref } from 'react'; import { Link as RALink } from 'react-aria-components'; -import { useResponsiveValue } from '../../hooks/useResponsiveValue'; import type { ButtonLinkProps } from './types'; +import { useStyles } from '../../hooks/useStyles'; /** @public */ export const ButtonLink = forwardRef( @@ -33,15 +33,18 @@ export const ButtonLink = forwardRef( ...rest } = props; - const responsiveSize = useResponsiveValue(size); - const responsiveVariant = useResponsiveValue(variant); + const { classNames, dataAttributes } = useStyles('Button', { + size, + variant, + }); + + const { classNames: classNamesButtonLink } = useStyles('ButtonLink'); return ( {iconStart} diff --git a/packages/canon/src/hooks/useStyles.ts b/packages/canon/src/hooks/useStyles.ts index d6c9d0c76b..19f9823236 100644 --- a/packages/canon/src/hooks/useStyles.ts +++ b/packages/canon/src/hooks/useStyles.ts @@ -15,7 +15,7 @@ */ import { useBreakpoint, breakpoints } from './useBreakpoint'; import { componentDefinitions } from '../utils/componentDefinitions'; -import type { ComponentDefinitionName } from '../types'; +import type { ComponentDefinitionName, ComponentClassNames } from '../types'; /** * Resolve a responsive value based on the current breakpoint @@ -61,9 +61,14 @@ function resolveResponsiveValue( export function useStyles( componentName: T, props: Record = {}, -) { +): { + classNames: ComponentClassNames; + dataAttributes: Record; + resolvedProps: Record; +} { const { breakpoint } = useBreakpoint(); - const classNames = componentDefinitions[componentName].classNames; + const classNames = componentDefinitions[componentName] + .classNames as ComponentClassNames; // Resolve responsive values and generate data attributes const dataAttributes: Record = {}; diff --git a/packages/canon/src/utils/componentDefinitions.ts b/packages/canon/src/utils/componentDefinitions.ts index 9d6926a002..97261f80e7 100644 --- a/packages/canon/src/utils/componentDefinitions.ts +++ b/packages/canon/src/utils/componentDefinitions.ts @@ -17,12 +17,40 @@ import type { ComponentDefinition } from '../types'; export const componentDefinitions = { - // Layout Components + Avatar: { + classNames: { + root: 'canon-AvatarRoot', + image: 'canon-AvatarImage', + fallback: 'canon-AvatarFallback', + }, + dataAttributes: { + size: ['small', 'medium', 'large'] as const, + }, + }, Box: { classNames: { root: 'canon-Box', }, }, + Button: { + classNames: { + root: 'canon-Button', + }, + dataAttributes: { + size: ['small', 'medium', 'large'] as const, + variant: ['primary', 'secondary', 'ghost'] as const, + }, + }, + ButtonIcon: { + classNames: { + root: 'canon-ButtonIcon', + }, + }, + ButtonLink: { + classNames: { + root: 'canon-ButtonLink', + }, + }, Container: { classNames: { @@ -66,33 +94,6 @@ export const componentDefinitions = { }, }, - // Form Components - Button: { - classNames: { - root: 'canon-Button', - }, - dataAttributes: { - size: ['small', 'medium', 'large'] as const, - variant: ['primary', 'secondary', 'ghost'] as const, - }, - }, - - ButtonIcon: { - classNames: { - root: 'canon-ButtonIcon', - }, - dataAttributes: { - size: ['small', 'medium', 'large'] as const, - variant: ['primary', 'secondary', 'ghost'] as const, - }, - }, - - ButtonLink: { - classNames: { - root: 'canon-ButtonLink', - }, - }, - TextField: { classNames: { root: 'canon-TextField', @@ -223,18 +224,6 @@ export const componentDefinitions = { }, }, - Avatar: { - classNames: { - root: 'canon-Avatar', - image: 'canon-Avatar-image', - fallback: 'canon-Avatar-fallback', - indicator: 'canon-Avatar-indicator', - }, - dataAttributes: { - size: ['small', 'medium', 'large'] as const, - }, - }, - Icon: { classNames: { root: 'canon-Icon', From 4ef1850bf1d73660be8991f0b8e8b35f7785df78 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Fri, 20 Jun 2025 16:34:55 +0100 Subject: [PATCH 3/8] Fix report Signed-off-by: Charles de Dreuille --- packages/canon/report.api.md | 253 ++++++++++++++++++ packages/canon/src/types.ts | 51 ++-- .../canon/src/utils/componentDefinitions.ts | 4 + 3 files changed, 286 insertions(+), 22 deletions(-) diff --git a/packages/canon/report.api.md b/packages/canon/report.api.md index abe30ea93b..e382eb1cc7 100644 --- a/packages/canon/report.api.md +++ b/packages/canon/report.api.md @@ -243,6 +243,9 @@ export interface CheckboxProps { value?: string; } +// @public +export type ClassNamesMap = Record; + // @public export const Collapsible: { Root: ForwardRefExoticComponent< @@ -265,6 +268,250 @@ export const Collapsible: { // @public (undocumented) export type Columns = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 'auto'; +// @public +export type ComponentClassNames = + (typeof componentDefinitions)[T]['classNames']; + +// @public +export interface ComponentDefinition { + // (undocumented) + classNames: ClassNamesMap; + // (undocumented) + dataAttributes?: DataAttributesMap; +} + +// @public +export type ComponentDefinitionName = keyof typeof componentDefinitions; + +// @public +export const componentDefinitions: { + readonly Avatar: { + readonly classNames: { + readonly root: 'canon-AvatarRoot'; + readonly image: 'canon-AvatarImage'; + readonly fallback: 'canon-AvatarFallback'; + }; + readonly dataAttributes: { + readonly size: readonly ['small', 'medium', 'large']; + }; + }; + readonly Box: { + readonly classNames: { + readonly root: 'canon-Box'; + }; + }; + readonly Button: { + readonly classNames: { + readonly root: 'canon-Button'; + }; + readonly dataAttributes: { + readonly size: readonly ['small', 'medium', 'large']; + readonly variant: readonly ['primary', 'secondary', 'ghost']; + }; + }; + readonly ButtonIcon: { + readonly classNames: { + readonly root: 'canon-ButtonIcon'; + }; + }; + readonly ButtonLink: { + readonly classNames: { + readonly root: 'canon-ButtonLink'; + }; + }; + readonly Container: { + readonly classNames: { + readonly root: 'canon-Container'; + }; + }; + readonly Flex: { + readonly classNames: { + readonly root: 'canon-Flex'; + }; + }; + readonly Grid: { + readonly classNames: { + readonly root: 'canon-Grid'; + }; + }; + readonly Text: { + readonly classNames: { + readonly root: 'canon-Text'; + }; + readonly dataAttributes: { + readonly variant: readonly ['body', 'caption', 'label']; + readonly weight: readonly ['regular', 'medium', 'bold']; + readonly color: readonly ['primary', 'secondary', 'muted']; + readonly truncate: readonly [true, false]; + }; + }; + readonly Heading: { + readonly classNames: { + readonly root: 'canon-Heading'; + }; + readonly dataAttributes: { + readonly variant: readonly ['title1', 'title2', 'title3', 'subtitle']; + readonly color: readonly ['primary', 'secondary', 'muted']; + readonly truncate: readonly [true, false]; + }; + }; + readonly TextField: { + readonly classNames: { + readonly root: 'canon-TextField'; + readonly input: 'canon-TextField-input'; + readonly label: 'canon-TextField-label'; + readonly error: 'canon-TextField-error'; + readonly helper: 'canon-TextField-helper'; + readonly container: 'canon-TextField-container'; + }; + readonly dataAttributes: { + readonly invalid: readonly [true, false]; + readonly disabled: readonly [true, false]; + }; + }; + readonly FieldLabel: { + readonly classNames: { + readonly root: 'canon-FieldLabel'; + readonly required: 'canon-FieldLabel-required'; + }; + readonly dataAttributes: { + readonly required: readonly [true, false]; + }; + }; + readonly Select: { + readonly classNames: { + readonly root: 'canon-Select'; + readonly trigger: 'canon-Select-trigger'; + readonly content: 'canon-Select-content'; + readonly item: 'canon-Select-item'; + readonly value: 'canon-Select-value'; + readonly icon: 'canon-Select-icon'; + readonly separator: 'canon-Select-separator'; + }; + readonly dataAttributes: { + readonly invalid: readonly [true, false]; + readonly disabled: readonly [true, false]; + readonly open: readonly [true, false]; + }; + }; + readonly Checkbox: { + readonly classNames: { + readonly root: 'canon-Checkbox'; + readonly input: 'canon-Checkbox-input'; + readonly indicator: 'canon-Checkbox-indicator'; + readonly label: 'canon-Checkbox-label'; + }; + readonly dataAttributes: { + readonly checked: readonly [true, false]; + readonly indeterminate: readonly [true, false]; + readonly disabled: readonly [true, false]; + }; + }; + readonly Switch: { + readonly classNames: { + readonly root: 'canon-Switch'; + readonly thumb: 'canon-Switch-thumb'; + readonly track: 'canon-Switch-track'; + }; + readonly dataAttributes: { + readonly checked: readonly [true, false]; + readonly disabled: readonly [true, false]; + }; + }; + readonly Link: { + readonly classNames: { + readonly root: 'canon-Link'; + }; + readonly dataAttributes: { + readonly variant: readonly ['primary', 'secondary']; + }; + }; + readonly Menu: { + readonly classNames: { + readonly root: 'canon-Menu'; + readonly trigger: 'canon-Menu-trigger'; + readonly content: 'canon-Menu-content'; + readonly item: 'canon-Menu-item'; + readonly separator: 'canon-Menu-separator'; + readonly label: 'canon-Menu-label'; + readonly group: 'canon-Menu-group'; + }; + }; + readonly Tabs: { + readonly classNames: { + readonly root: 'canon-Tabs'; + readonly list: 'canon-Tabs-list'; + readonly tab: 'canon-Tabs-tab'; + readonly panel: 'canon-Tabs-panel'; + readonly trigger: 'canon-Tabs-trigger'; + }; + }; + readonly Table: { + readonly classNames: { + readonly root: 'canon-Table'; + readonly header: 'canon-Table-header'; + readonly body: 'canon-Table-body'; + readonly footer: 'canon-Table-footer'; + readonly row: 'canon-Table-row'; + readonly cell: 'canon-Table-cell'; + readonly headerCell: 'canon-Table-headerCell'; + readonly caption: 'canon-Table-caption'; + }; + }; + readonly DataTable: { + readonly classNames: { + readonly root: 'canon-DataTable'; + readonly container: 'canon-DataTable-container'; + readonly header: 'canon-DataTable-header'; + readonly body: 'canon-DataTable-body'; + readonly row: 'canon-DataTable-row'; + readonly cell: 'canon-DataTable-cell'; + readonly headerCell: 'canon-DataTable-headerCell'; + readonly toolbar: 'canon-DataTable-toolbar'; + readonly pagination: 'canon-DataTable-pagination'; + readonly search: 'canon-DataTable-search'; + readonly filters: 'canon-DataTable-filters'; + }; + }; + readonly Icon: { + readonly classNames: { + readonly root: 'canon-Icon'; + }; + readonly dataAttributes: { + readonly size: readonly ['small', 'medium', 'large']; + }; + }; + readonly Tooltip: { + readonly classNames: { + readonly root: 'canon-Tooltip'; + readonly trigger: 'canon-Tooltip-trigger'; + readonly content: 'canon-Tooltip-content'; + readonly arrow: 'canon-Tooltip-arrow'; + }; + }; + readonly Collapsible: { + readonly classNames: { + readonly root: 'canon-Collapsible'; + readonly trigger: 'canon-Collapsible-trigger'; + readonly content: 'canon-Collapsible-content'; + readonly icon: 'canon-Collapsible-icon'; + }; + readonly dataAttributes: { + readonly open: readonly [true, false]; + }; + }; + readonly ScrollArea: { + readonly classNames: { + readonly root: 'canon-ScrollArea'; + readonly viewport: 'canon-ScrollArea-viewport'; + readonly scrollbar: 'canon-ScrollArea-scrollbar'; + readonly thumb: 'canon-ScrollArea-thumb'; + readonly corner: 'canon-ScrollArea-corner'; + readonly track: 'canon-ScrollArea-track'; + }; + }; +}; + // @public (undocumented) export const Container: ForwardRefExoticComponent< ContainerProps & RefAttributes @@ -292,6 +539,12 @@ export interface ContainerProps { style?: React.CSSProperties; } +// @public +export type DataAttributesMap = Record; + +// @public +export type DataAttributeValues = readonly (string | number | boolean)[]; + // @public export const DataTable: { Root: ( diff --git a/packages/canon/src/types.ts b/packages/canon/src/types.ts index 056d4772ba..073aa0d6cb 100644 --- a/packages/canon/src/types.ts +++ b/packages/canon/src/types.ts @@ -134,35 +134,42 @@ export interface UtilityProps extends SpaceProps { rowSpan?: Responsive; } -// Base types for the component styles structure -type ClassNamesMap = Record; -type DataAttributeValues = readonly (string | number | boolean)[]; -type DataAttributesMap = Record; +/** + * Base type for the component styles structure + * @public + */ +export type ClassNamesMap = Record; +/** + * Base type for the component styles structure + * @public + */ +export type DataAttributeValues = readonly (string | number | boolean)[]; + +/** + * Base type for the component styles structure + * @public + */ +export type DataAttributesMap = Record; + +/** + * Base type for the component styles structure + * @public + */ export interface ComponentDefinition { classNames: ClassNamesMap; dataAttributes?: DataAttributesMap; } -// Type utilities for extracting information from the component styles +/** + * Type utilities for extracting information from the component styles + * @public + */ export type ComponentDefinitionName = keyof typeof componentDefinitions; +/** + * Helper type to extract class names for a component + * @public + */ export type ComponentClassNames = (typeof componentDefinitions)[T]['classNames']; - -export type ComponentDataAttributes = - (typeof componentDefinitions)[T] extends { dataAttributes: infer DA } - ? DA - : Record; - -export type ComponentDataAttributeProps = { - [K in keyof ComponentDataAttributes]?: ComponentDataAttributes[K] extends readonly (infer U)[] - ? U - : never; -}; - -// Helper type to check if a component has data attributes -export type HasDataAttributes = - (typeof componentDefinitions)[T] extends { dataAttributes: any } - ? true - : false; diff --git a/packages/canon/src/utils/componentDefinitions.ts b/packages/canon/src/utils/componentDefinitions.ts index 97261f80e7..009b61c9f0 100644 --- a/packages/canon/src/utils/componentDefinitions.ts +++ b/packages/canon/src/utils/componentDefinitions.ts @@ -16,6 +16,10 @@ import type { ComponentDefinition } from '../types'; +/** + * Component definitions for the Canon library + * @public + */ export const componentDefinitions = { Avatar: { classNames: { From 81835d38c2e026939bb70dafb488f330bc6c4848 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Fri, 20 Jun 2025 16:51:40 +0100 Subject: [PATCH 4/8] Add Checkbox Signed-off-by: Charles de Dreuille --- .../src/components/Checkbox/Checkbox.tsx | 5 +++- .../canon/src/utils/componentDefinitions.ts | 25 ++++++++----------- 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/packages/canon/src/components/Checkbox/Checkbox.tsx b/packages/canon/src/components/Checkbox/Checkbox.tsx index 5bf6520891..8fbb4d40af 100644 --- a/packages/canon/src/components/Checkbox/Checkbox.tsx +++ b/packages/canon/src/components/Checkbox/Checkbox.tsx @@ -18,6 +18,7 @@ import { forwardRef } from 'react'; import { Checkbox as CheckboxPrimitive } from '@base-ui-components/react/checkbox'; import { Icon } from '@backstage/canon'; import type { CheckboxProps } from './types'; +import { useStyles } from '../../hooks/useStyles'; import clsx from 'clsx'; /** @public */ @@ -35,10 +36,12 @@ export const Checkbox = forwardRef( style, } = props; + const { classNames } = useStyles('Checkbox'); + const checkboxElement = ( Date: Fri, 20 Jun 2025 16:55:26 +0100 Subject: [PATCH 5/8] Create shaggy-cobras-rhyme.md Signed-off-by: Charles de Dreuille --- .changeset/shaggy-cobras-rhyme.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/shaggy-cobras-rhyme.md diff --git a/.changeset/shaggy-cobras-rhyme.md b/.changeset/shaggy-cobras-rhyme.md new file mode 100644 index 0000000000..d8adefd112 --- /dev/null +++ b/.changeset/shaggy-cobras-rhyme.md @@ -0,0 +1,5 @@ +--- +'@backstage/canon': patch +--- + +We are transforming how we structure our class names and data attributes definitions for all components. They are now all set in the same place. From 6febe2a7a378fe093086cb7949237f3512d7bbc1 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Mon, 23 Jun 2025 13:25:41 +0100 Subject: [PATCH 6/8] Convert more components Signed-off-by: Charles de Dreuille --- packages/canon/src/components/Box/Box.tsx | 4 +- .../src/components/Checkbox/Checkbox.tsx | 4 +- .../components/Collapsible/Collapsible.tsx | 55 ++-- .../src/components/Container/Container.tsx | 5 +- .../src/components/FieldLabel/FieldLabel.tsx | 14 +- packages/canon/src/components/Flex/Flex.tsx | 4 +- packages/canon/src/components/Grid/Grid.tsx | 8 +- .../canon/src/components/Heading/Heading.tsx | 15 +- packages/canon/src/components/Icon/Icon.tsx | 5 +- packages/canon/src/components/Link/Link.tsx | 10 +- packages/canon/src/components/Menu/Menu.tsx | 281 +++++++++++------- .../canon/src/utils/componentDefinitions.ts | 140 +++++---- 12 files changed, 315 insertions(+), 230 deletions(-) diff --git a/packages/canon/src/components/Box/Box.tsx b/packages/canon/src/components/Box/Box.tsx index 8718c77724..4273ee86f9 100644 --- a/packages/canon/src/components/Box/Box.tsx +++ b/packages/canon/src/components/Box/Box.tsx @@ -24,6 +24,7 @@ import { widthPropDefs } from '../../props/width.props'; import { heightPropDefs } from '../../props/height.props'; import { positionPropDefs } from '../../props/position.props'; import { displayPropDefs } from '../../props/display.props'; +import { useStyles } from '../../hooks/useStyles'; /** @public */ export const Box = forwardRef((props, ref) => { @@ -38,11 +39,12 @@ export const Box = forwardRef((props, ref) => { ...boxPropDefs, }; + const { classNames } = useStyles('Box'); const { className, style } = extractProps(props, propDefs); return createElement(props.as || 'div', { ref, - className: clsx('canon-Box', className), + className: clsx(classNames.root, className), style, children, }); diff --git a/packages/canon/src/components/Checkbox/Checkbox.tsx b/packages/canon/src/components/Checkbox/Checkbox.tsx index 8fbb4d40af..aaa6fb105b 100644 --- a/packages/canon/src/components/Checkbox/Checkbox.tsx +++ b/packages/canon/src/components/Checkbox/Checkbox.tsx @@ -50,14 +50,14 @@ export const Checkbox = forwardRef( value={value} style={style} > - + ); return label ? ( -