From cb2ac11b1bdb9d04fc2c5276f3f56c6e8ee4d9ba Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Fri, 20 Jun 2025 14:42:27 +0100 Subject: [PATCH] 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;