Create first version of useStyles hook

Signed-off-by: Charles de Dreuille <charles.dedreuille@gmail.com>
This commit is contained in:
Charles de Dreuille
2025-06-20 14:42:27 +01:00
parent e3efb1e90c
commit cb2ac11b1b
5 changed files with 413 additions and 6 deletions
@@ -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 (
<RAButton
className={clsx('canon-Button', className)}
data-variant={responsiveVariant}
data-size={responsiveSize}
className={clsx(classNames.root, className)}
ref={ref}
{...dataAttributes}
{...rest}
>
{iconStart}
+87
View File
@@ -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<string, string>,
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<T extends ComponentDefinitionName>(
componentName: T,
props: Record<string, any> = {},
) {
const { breakpoint } = useBreakpoint();
const classNames = componentDefinitions[componentName].classNames;
// Resolve responsive values and generate data attributes
const dataAttributes: Record<string, string> = {};
const resolvedProps: Record<string, string> = {};
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
};
}
+3
View File
@@ -57,3 +57,6 @@ export * from './props';
// Hooks
export { useBreakpoint } from './hooks/useBreakpoint';
// Component Definitions
export * from './utils/componentDefinitions';
+35
View File
@@ -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<JustifyContent>;
rowSpan?: Responsive<Columns | 'full'>;
}
// Base types for the component styles structure
type ClassNamesMap = Record<string, string>;
type DataAttributeValues = readonly (string | number | boolean)[];
type DataAttributesMap = Record<string, DataAttributeValues>;
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<T extends ComponentDefinitionName> =
(typeof componentDefinitions)[T]['classNames'];
export type ComponentDataAttributes<T extends ComponentDefinitionName> =
(typeof componentDefinitions)[T] extends { dataAttributes: infer DA }
? DA
: Record<string, never>;
export type ComponentDataAttributeProps<T extends ComponentDefinitionName> = {
[K in keyof ComponentDataAttributes<T>]?: ComponentDataAttributes<T>[K] extends readonly (infer U)[]
? U
: never;
};
// Helper type to check if a component has data attributes
export type HasDataAttributes<T extends ComponentDefinitionName> =
(typeof componentDefinitions)[T] extends { dataAttributes: any }
? true
: false;
@@ -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<string, ComponentDefinition>;