diff --git a/.cursor/rules/component-structure.mdc b/.cursor/rules/component-structure.mdc new file mode 100644 index 0000000..15a4755 --- /dev/null +++ b/.cursor/rules/component-structure.mdc @@ -0,0 +1,384 @@ +--- +name: component-structure +description: Rules for component structure and organization based on project conventions +--- + +# Overview + +All components must follow a consistent structure to ensure maintainability and readability. This rule defines the exact order and organization of component code. + +## Component Principles + +1. **Separation of logic and styling** - Keep styling in CSS files, logic in components +2. **Consistent structure** - All components follow the same structure pattern + +## Component File Structure + +### 1. Type Definitions (Props) + +Define component props **first**, before the component implementation: + +```tsx +export type InputProps = Omit, 'value'> + & Partial> + & Partial + & { editCompleteOptions?: EditCompleteOptions } +``` + +**Important Rules:** +- **Never overwrite HTMLAttributes directly** - Always use `Omit` or intersection types +- Use descriptive names even if longer (e.g., `onValueChange` instead of `onChange` if parameters differ) +- Combine multiple type sources using intersection types (`&`) + +### 2. Documentation + +Provide a JSDoc comment describing the component: + +```tsx +/** + * A Component for inputting text or other information + * + * Its state is managed must be managed by the parent + */ +``` + +### 3. Component Implementation + +Use `forwardRef` for components that need ref forwarding: + +```tsx +export const Input = forwardRef(function Input({ + value, + onValueChange, + editCompleteOptions, + disabled = false, + invalid = false, + ...props +}, forwardedRef) { + // Component body +}) +``` + +**Function Naming:** +- Use the component name as the function name inside `forwardRef` +- This helps with React DevTools debugging + +## Component Body Order + +Inside the component function, follow this exact order: + +### 1. States + +```tsx +const [isExpanded, setIsExpanded] = useState(false) +const [ids, setIds] = useState({...}) +``` + +**Exception:** Refs can be placed near their corresponding hooks if semantically more coherent: +```tsx +const innerRef = useRef(null) +useImperativeHandle(forwardedRef, () => innerRef.current) +``` + +### 2. Constants and Memos + +```tsx +const { + onBlur: allowEditCompleteOnBlur, + afterDelay, + delay, + allowEnterComplete, +} = { ...defaultEditCompleteOptions, ...editCompleteOptions } + +const contextValue = useMemo(() => ({ + isExpanded: !!isExpanded, + toggle, + setIsExpanded, + ids, + setIds, + disabled +}), [isExpanded, toggle, setIsExpanded, ids, disabled]) +``` + +### 3. Other Hooks and Effects + +```tsx +const { restartTimer, clearTimer } = useDelay({ + delay, + disabled: !afterDelay, +}) + +const { focusNext } = useFocusManagement() + +useEffect(() => { + // Effect logic +}, [dependencies]) +``` + +## JSX Element Attribute Order + +When returning JSX, attributes must be in this **exact order**: + +### 1. Props Spread + +```tsx + { + props.onKeyDown?.(event) + // Custom logic + }} + onBlur={(event) => { + props.onBlur?.(event) + // Custom logic + }} + onClick={(event) => { + props.onClick?.(event) + if (allowContainerToggle) { + toggle() + } + }} +``` + +**Important:** Always call the original prop handler first (`props.onKeyDown?.(event)`) unless your action **must** prevent it. + +### 5. Data Attributes + +```tsx + data-name={PropsUtil.dataAttributes.name('input', props)} + data-value={PropsUtil.dataAttributes.bool(!!value)} + data-expanded={PropsUtil.dataAttributes.bool(isExpanded)} + data-disabled={PropsUtil.dataAttributes.bool(disabled)} + data-invalid={PropsUtil.dataAttributes.bool(requiredAndNoValue)} + {...PropsUtil.dataAttributes.interactionStates({ ...props, invalid })} +``` + +**Required Pattern:** +- **Always use `PropsUtil.dataAttributes.bool(value)` for boolean data attributes** - Returns `''` if true, `undefined` if false +- Use `PropsUtil.dataAttributes.name(defaultName, props)` for component identification +- Use `PropsUtil.dataAttributes.interactionStates()` for form interaction states + +**Never use manual boolean patterns:** +- ❌ `data-expanded={isExpanded ? '' : undefined}` - Use `PropsUtil.dataAttributes.bool(isExpanded)` instead +- ❌ `data-disabled={disabled ? '' : undefined}` - Use `PropsUtil.dataAttributes.bool(disabled)` instead + +### 6. ARIA Attributes + +```tsx + aria-invalid={props['aria-invalid'] ?? invalid} + aria-disabled={props['aria-disabled'] ?? disabled} + aria-expanded={isExpanded} + aria-controls={ids.content} + {...PropsUtil.aria.interactionStates({ ...props, invalid }, props)} +``` + +**Pattern:** +- Allow props to override defaults: `props['aria-invalid'] ?? invalid` +- Use `PropsUtil.aria.interactionStates()` for consistent ARIA interaction states + +### 7. ClassName and Style + +```tsx + className={clsx('custom-class', { 'conditional-class': condition })} + style={customStyle} +/> +``` + +## Complete Example + +```tsx +// 1. Type Definition +export type InputProps = Omit, 'value'> + & Partial> + & Partial + & { editCompleteOptions?: EditCompleteOptions } + +// 2. Documentation +/** + * A Component for inputting text or other information + * + * Its state is managed must be managed by the parent + */ + +// 3. Component Implementation +export const Input = forwardRef(function Input({ + value, + onValueChange, + editCompleteOptions, + disabled = false, + invalid = false, + ...props +}, forwardedRef) { + // 1. Constants and Memos + const { + onBlur: allowEditCompleteOnBlur, + afterDelay, + delay, + allowEnterComplete, + } = { ...defaultEditCompleteOptions, ...editCompleteOptions } + + // 2. Hooks + const { restartTimer, clearTimer } = useDelay({ + delay, + disabled: !afterDelay, + }) + + const innerRef = useRef(null) + useImperativeHandle(forwardedRef, () => innerRef.current) + + const { focusNext } = useFocusManagement() + + // 3. Return JSX + return ( + { + props.onKeyDown?.(event) + if (!allowEnterComplete) { + return + } + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault() + innerRef.current?.blur() + onEditComplete?.(event.target.value) + focusNext() + } + }} + onBlur={(event) => { + props.onBlur?.(event) + if (allowEditCompleteOnBlur) { + onEditComplete?.(event.target.value) + clearTimer() + } + }} + onChange={(event) => { + props.onChange?.(event) + const value = event.target.value + restartTimer(() => { + innerRef.current?.blur() + onEditComplete?.(value) + }) + onValueChange?.(value) + }} + // 5. Data attributes + data-name={PropsUtil.dataAttributes.name('input', props)} + data-value={PropsUtil.dataAttributes.bool(!!value)} + data-invalid={PropsUtil.dataAttributes.bool(invalid)} + {...PropsUtil.dataAttributes.interactionStates({ ...props, invalid })} + // 6. ARIA + {...PropsUtil.aria.interactionStates({ ...props, invalid }, props)} + // 7. ClassName and style (if needed) + /> + ) +}) +``` + +## Naming Conventions + +### Callback Functions + +- **Always use present tense**: `onComplete`, `onChange`, `onValueChange` +- **Never use native HTML callback names** unless they share the exact same parameters +- **Use descriptive names**: `onValueChange` instead of `onChange` if the signature differs + +**Examples:** +- ✅ `onValueChange` - Custom callback with different signature +- ✅ `onEditComplete` - Custom callback +- ❌ `onChange` - Only if it matches native HTML `onChange` exactly + +### Component Names + +- Use PascalCase: `Input`, `ExpandableRoot`, `PropertyBase` +- Use descriptive names: `ExpandableHeader` not `Header` + +## Context and Sub-Components + +For components with context and sub-components: + +### Context Definition + +```tsx +// +// Context +// + +type ExpandableContextState = { + ids: ExpandableContextIdsState, + setIds: Dispatch>, + disabled: boolean, + isExpanded: boolean, + toggle: () => void, + setIsExpanded: Dispatch>, +} + +const ExpandableContext = createContext(null) + +function useExpandableContext() { + const context = useContext(ExpandableContext) + if (!context) { + throw new Error('Expandable components must be used within an ExpandableRoot') + } + return context +} +``` + +### Sub-Component Sections + +Use comment separators: + +```tsx +// +// ExpandableRoot +// + +export type ExpandableRootProps = ... + +export const ExpandableRoot = ... + +// +// ExpandableHeader +// + +export type ExpandableHeaderProps = ... + +export const ExpandableHeader = ... +``` + +## Best Practices + +1. **Follow the exact order** - Don't deviate from the specified structure +2. **Always call original handlers** - Call `props.onClick?.(event)` before your logic +3. **Use utilities** - Leverage `PropsUtil` for data attributes and ARIA +4. **Document components** - Always include JSDoc comments +5. **Type safety** - Never overwrite HTMLAttributes, use `Omit` or intersections +6. **Consistent naming** - Use present tense for callbacks, PascalCase for components +7. **Separation of concerns** - Keep styling in CSS files, not in components + +@src/components/** diff --git a/.cursor/rules/storybook-stories.mdc b/.cursor/rules/storybook-stories.mdc new file mode 100644 index 0000000..d5c1416 --- /dev/null +++ b/.cursor/rules/storybook-stories.mdc @@ -0,0 +1,313 @@ +--- +name: storybook-stories +description: Rules for defining Storybook stories including prop order and structure +--- + +# Overview + +All Storybook stories must follow a consistent structure to ensure maintainability and readability. This rule defines the exact order and organization of story code. + +## Story Structure + +### 1. Meta Definition + +Define the meta object with explicit type annotation: + +```tsx +const meta: Meta = { + component: MyComponent, +} + +export default meta +type Story = StoryObj; +``` + +**Important:** Always use explicit type annotation `meta: Meta` instead of `satisfies`. + +### 2. Story Naming + +**Always name the story the same as the file it's in** (converted to camelCase): + +- File: `PropertyBase.stories.tsx` → Story: `propertyBase` +- File: `NumberProperty.stories.tsx` → Story: `numberProperty` +- File: `MyComponent.stories.tsx` → Story: `myComponent` + +```tsx +export const propertyBase: Story = { + // story definition +} +``` + +### 3. Story Implementation + +**Always use `render` function or the component directly** - never use only `args` without a render function when the component needs state management or custom logic. + +## Args Order + +Props in `args` must follow this **exact order**: + +### 1. Visual Props (affect appearance/state) + +Props that change how the component looks or behaves visually: + +```tsx +args: { + name: 'Property', + required: false, + value: undefined, + disabled: false, + readOnly: false, + size: 'md', + color: 'primary', + // ... other visual props +} +``` + +**Common visual props:** +- `name`, `label`, `title` +- `required`, `disabled`, `readOnly`, `invalid` +- `value`, `defaultValue` +- `size`, `color`, `coloringStyle`, `layout` +- `type`, `mode`, `variant` +- `className`, `style` + +### 2. Children and JSX Content + +Props that contain JSX or React nodes: + +```tsx +args: { + // ... visual props + icon: , + children: options.map(option => ( + + {option} + + )), + // ... or + children: ({ required, hasValue, invalid }) => ( +
Content
+ ), +} +``` + +**Common JSX props:** +- `children` (JSX elements or render functions) +- `icon`, `label`, `titleElement` +- Any prop that accepts ReactNode + +### 3. Callbacks (all actively used by component) + +**All callbacks that the component uses must be included as actions:** + +```tsx +args: { + // ... visual props + // ... children/JSX + onValueChange: action('onValueChange'), + onEditComplete: action('onEditComplete'), + onRemove: action('onRemove'), + onValueClear: action('onValueClear'), + onClick: action('onClick'), + onSubmit: action('onSubmit'), + // ... all other callbacks +} +``` + +**Important:** +- **Always use `action()` from `storybook/actions`** for all callbacks +- Include **all callbacks** that the component actively uses, not just some +- Order callbacks alphabetically or by logical grouping + +## Render Function Pattern + +### Using Uncontrolled Variants (Preferred) + +**If the component has an `Uncontrolled` variant, use it instead of managing state manually:** + +```tsx +import { MyComponentUncontrolled } from '@/src/components/...' + +export const myComponent: Story = { + args: { + // ... props + onValueChange: action('onValueChange'), + }, + render: (args) => { + return ( + { + args.onValueChange?.(val) + }} + /> + ) + } +} +``` + +**Benefits:** +- Simpler code - no state management needed +- Component handles state internally +- Still allows action callbacks to be called + +### Manual State Management (When No Uncontrolled Variant) + +When the component doesn't have an uncontrolled variant, manage state manually: + +```tsx +render: ({ value, ...props }) => { + const [usedValue, setUsedValue] = useState(value) + + useEffect(() => { + setUsedValue(value) + }, [value]) + + return ( + { + props.onValueChange?.(val) + setUsedValue(val) + }} + onEditComplete={(val) => { + props.onEditComplete?.(val) + setUsedValue(val) + }} + onRemove={() => { + props.onRemove?.() + setUsedValue(undefined) + }} + /> + ) +} +``` + +**Pattern:** +1. Check if component has `ComponentNameUncontrolled` variant +2. If yes, use uncontrolled variant and call action callbacks +3. If no, extract state-managed props (like `value`) from args +4. Create local state with `useState` +5. Sync with `useEffect` if needed +6. Call action callbacks **first**, then update local state +7. Pass all props including callbacks to component + +## Complete Examples + +### Example 1: Using Uncontrolled Variant + +```tsx +import type { Meta, StoryObj } from '@storybook/nextjs' +import { InputUncontrolled } from '@/src/components/user-interaction/input/Input' +import { action } from 'storybook/actions' + +const meta: Meta = { + component: InputUncontrolled, +} + +export default meta +type Story = StoryObj; + +export const input: Story = { + args: { + // 1. Visual props + value: '', + disabled: false, + readOnly: false, + // 2. Children/JSX (none) + // 3. Callbacks + onValueChange: action('onValueChange'), + onEditComplete: action('onEditComplete'), + }, + render: (args) => { + return ( + { + args.onValueChange?.(val) + }} + onEditComplete={(val) => { + args.onEditComplete?.(val) + }} + /> + ) + } +} +``` + +### Example 2: Manual State Management (No Uncontrolled Variant) + +```tsx +import type { Meta, StoryObj } from '@storybook/nextjs' +import { useEffect, useState } from 'react' +import { NumberProperty } from '@/src/components/user-interaction/properties/NumberProperty' +import { action } from 'storybook/actions' + +const meta: Meta = { + component: NumberProperty, +} + +export default meta +type Story = StoryObj; + +export const numberProperty: Story = { + args: { + // 1. Visual props + name: 'Property', + required: false, + value: undefined, + suffix: 'kg', + readOnly: false, + className: '', + // 2. Children/JSX (none in this case) + // 3. Callbacks + onValueChange: action('onValueChange'), + onEditComplete: action('onEditComplete'), + onRemove: action('onRemove'), + onValueClear: action('onValueClear'), + }, + render: ({ value, ...props }) => { + const [usedValue, setUsedValue] = useState(value) + + useEffect(() => { + setUsedValue(value) + }, [value]) + + return ( + { + props.onValueChange?.(val) + setUsedValue(val) + }} + onEditComplete={(val) => { + props.onEditComplete?.(val) + setUsedValue(val) + }} + onRemove={() => { + props.onRemove?.() + setUsedValue(undefined) + }} + onValueClear={() => { + props.onValueClear?.() + setUsedValue(undefined) + }} + /> + ) + } +} +``` + +## Best Practices + +1. **Follow exact order** - Visual props → Children/JSX → Callbacks +2. **Include all callbacks** - Don't omit callbacks that the component uses +3. **Use actions** - Always wrap callbacks with `action()` from `storybook/actions` +4. **Name consistently** - Story name must match filename (camelCase) +5. **Use render function** - When component needs state management or custom logic +6. **Call actions first** - In render functions, call action callbacks before updating state +7. **Sync state properly** - Use `useEffect` to sync local state with args when needed + +@stories/** diff --git a/.cursor/rules/theming.mdc b/.cursor/rules/theming.mdc new file mode 100644 index 0000000..9bf29fa --- /dev/null +++ b/.cursor/rules/theming.mdc @@ -0,0 +1,301 @@ +--- +name: theming +description: Rules for component theming using data attributes and CSS file structure +--- + +# Overview + +Components should use data attributes for styling and state management. All component styles are defined in CSS files within the theme system, following a consistent pattern based on the Expandable component. + +## Data Attributes Pattern + +### Component Identification + +Every component element should have a `data-name` attribute that uniquely identifies it: + +```tsx +
+
...
+
...
+
+``` + +**Naming Convention:** +- Use kebab-case: `my-component-root`, `my-component-header` +- For sub-components, use descriptive names: `expandable-root`, `expandable-header`, `expandable-content` +- For container elements: `dialog-container`, `day-picker-container` + +### Boolean Data Attributes + +Boolean data attributes use an empty string when `true` and `undefined` when `false`: + +```tsx +
+``` + +**Available Utilities:** +- `PropsUtil.dataAttributes.bool(value)` - Returns `''` if true, `undefined` if false +- `PropsUtil.dataAttributes.name(defaultName, props)` - Gets `data-name` from props or uses default + +**Example from Expandable:** +```tsx +data-expanded={isExpanded ? '' : undefined} +data-disabled={disabled ? '' : undefined} +data-containertoggleable={allowContainerToggle ? '' : undefined} +``` + +### State Data Attributes + +For state values, use string attributes: + +```tsx +
// "opening" | "closing" | "opened" | "closed" +
// "top" | "center" | "bottom" +
// "xs" | "sm" | "md" | "lg" +``` + +### Interaction States + +For form elements and interactive components, use the interaction states utility: + +```tsx +import { PropsUtil } from '@/src/utils/propsUtil' + +
+``` + +This automatically sets: `data-disabled`, `data-invalid`, `data-readonly`, `data-required` + +## CSS File Structure + +### File Location + +Component CSS files are located in `src/style/theme/components/` and follow the naming pattern: +- Component file: `src/components/layout/Expandable.tsx` +- CSS file: `src/style/theme/components/expandable.css` + +### CSS Layer and Selectors + +All component styles must be wrapped in `@layer components` and target data attributes: + +```css +@layer components { + [data-name="my-component-root"]:not(.default-style-none) { + @apply flex-col-0 surface coloring-solid rounded-lg; + } + + [data-name="my-component-header"]:not(.default-style-none) { + @apply flex-row-2 justify-between items-center; + + &:not([data-disabled]) { + @apply cursor-pointer; + } + + &[data-disabled] { + @apply cursor-not-allowed disabled; + } + } +} +``` + +**Key Rules:** +1. Always use `@layer components` wrapper +2. Target `[data-name="component-name"]` selectors +3. Include `:not(.default-style-none)` to allow opt-out styling +4. Use Tailwind `@apply` directives for utility classes +5. Use nested selectors for state-based styling: `&[data-expanded]`, `&:not([data-disabled])` + +### State-Based Styling + +Style different states using attribute selectors: + +```css +[data-name="expandable-content"]:not(.default-style-none) { + @apply transition-all ease-in-out; + + &:not([data-expanded]) { + @apply max-h-0 opacity-0 overflow-hidden; + } + + &[data-expanded] { + @apply max-h-24 opacity-100; + } + + &[data-state="opening"], + &[data-state="closing"] { + @apply overflow-hidden; + } + + &[data-state="opened"] { + @apply overflow-y-auto; + } +} +``` + +### Custom Utilities + +You can define custom utilities using `@utility`: + +```css +@utility expadable-content-h-* { + height: calc(var(--spacing) * --value(number)); + + &[data-expanded] { + max-height: calc(var(--spacing) * --value(number)); + } +} +``` + +### Importing CSS Files + +All component CSS files must be imported in `src/style/theme/components/index.css`: + +```css +@import "./expandable.css"; +@import "./button.css"; +@import "./my-component.css"; +``` + +**Important:** Add your new CSS file import to maintain the build order. + +## Complete Example: Expandable Component + +### Component (Expandable.tsx) + +```tsx +
+
+ {children} +
+
+ {content} +
+
+``` + +### CSS (expandable.css) + +```css +@layer components { + [data-name="expandable-root"]:not(.default-style-none) { + @apply flex-col-0 surface coloring-solid rounded-lg shadow-sm; + + &:not([data-disabled])[data-containertoggleable] { + @apply cursor-pointer; + } + } + + [data-name="expandable-header"]:not(.default-style-none) { + @apply flex-row-2 justify-between items-center py-2 px-4 rounded-lg; + + &:not([data-disabled]) { + @apply cursor-pointer surface coloring-solid-hover; + } + + &[data-disabled] { + @apply cursor-not-allowed disabled coloring-solid; + } + } + + [data-name="expandable-content"]:not(.default-style-none) { + @apply flex-col-2 px-4 transition-all ease-in-out; + + &:not([data-expanded]) { + @apply max-h-0 opacity-0 overflow-hidden; + } + + &[data-expanded] { + @apply max-h-24 opacity-100; + } + + &[data-state="opening"], + &[data-state="closing"] { + @apply overflow-hidden; + } + } +} +``` + +### Group Classes + +**Avoid using `group` classes when possible.** Prefer data attributes and direct selectors for styling relationships. + +**If `group` must be used:** +1. **Never apply `group` in CSS files** - Do not use `@apply group` or `group` in CSS +2. **Apply `group` on the component's className** - Add it directly to the root element's `className` prop +3. **Use named groups** - Always use named groups with the pattern `group/component-name` (e.g., `group/property`, `group/table-header-cell`) + +**Correct Pattern:** +```tsx +// Component +
+
...
+
...
+
+``` + +```css +/* CSS - Use group-hover/component-name */ +[data-name="property-title"]:not(.default-style-none) { + @apply group-hover/property:border-primary; +} +``` + +**Incorrect Patterns:** +```css +/* ❌ Don't apply group in CSS */ +[data-name="property-root"]:not(.default-style-none) { + @apply flex-row-0 group; /* Wrong! */ +} +``` + +```tsx +/* ❌ Don't use unnamed group */ +
/* Wrong! */ +``` + +**Examples from codebase:** +- `group/property` - For property component hover states +- `group/table-header-cell` - For table header cell interactions +- `group/slide` - For carousel slide interactions + +## Best Practices + +1. **Separation of Concerns**: Keep styling in CSS files, not inline styles or className logic +2. **Data Attributes First**: Use data attributes for all state-based styling +3. **Opt-Out Support**: Always include `:not(.default-style-none)` for flexibility +4. **Consistent Naming**: Use kebab-case for `data-name` attributes matching component structure +5. **State Management**: Use boolean attributes for on/off states, string attributes for multi-value states +6. **Utility Classes**: Prefer Tailwind utilities via `@apply` over custom CSS when possible +7. **File Organization**: One CSS file per component, imported in `components/index.css` +8. **Avoid Groups**: Prefer data attributes over group classes; if groups are necessary, use named groups on className + +@src/style/theme/components/** +@src/components/** diff --git a/.cursor/rules/translations.mdc b/.cursor/rules/translations.mdc new file mode 100644 index 0000000..898938c --- /dev/null +++ b/.cursor/rules/translations.mdc @@ -0,0 +1,51 @@ +--- +name: translations +description: Rules for adding translations and how to apply the update +--- + +# Overview + +To add translations edit the corresponding file in the locales folder and follow these rules: +1. always add translations for all languages +2. Keep the keys sorted alphabetically +3. Use lower camel case for the translation +4. Prefix Selections with "s" +5. Prefix Plural Selections with "p" if the translation does not include the count and with "n" if it includes the count +6. Prefix Replacements with "r" +7. In case of both Selection and Replacement choose the Selection prefix +8. After adding all translations, run `npm run build-intl` to update the generated translation file + +## ICU Message Type Prefixes + +### Selection (prefix "s") +ICU `select` statements that choose between options based on a value: +- Example: `"sThemeMode": "{theme, select, dark{Dark} light{Light} system{System}}"` +- Example: `"sGender": "{gender, select, male{Male} female{Female} other{Other}}"` + +### Plural Selection (prefix "p" or "n") +ICU `plural` statements for pluralization: +- **"p" prefix**: When the translation does NOT include the count number + - Example: `"pThemes": "{count, plural, =1{Theme} other{Themes}}"` +- **"n" prefix**: When the translation DOES include the count (using `#` or `{count}`) + - Example: `"nItems": "{count, plural, =1{# item} other{# items}}"` + +### Replacement (prefix "r") +When there is text outside the ICU statement (before or after): +- Example: `"rSortingOrderAfter": "Applied {otherSortings, plural, ...}"` + +### Combined Cases +If a translation has both Selection/Plural AND Replacement (text outside), use the Selection prefix (rule 7). + +## Examples + +**Correct:** +- `"clearValue": "Clear Value"` - simple string, no prefix +- `"sThemeMode": "{theme, select, ...}"` - selection, "s" prefix +- `"pThemes": "{count, plural, =1{Theme} other{Themes}}"` - plural without count, "p" prefix +- `"rSortingOrderAfter": "Applied {otherSortings, plural, ...}"` - replacement, "r" prefix + +**Incorrect:** +- `"themeMode": "{theme, select, ...}"` - missing "s" prefix +- `"themes": "{count, plural, ...}"` - missing "p" prefix + +@locales/** diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index b6e9d1b..c0c6fc3 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -4,9 +4,21 @@ on: push: branches: - main + paths: + - 'src/components/**' + - 'tests/**' + - 'package.json' + - 'package-lock.json' + - '.github/workflows/test.yaml' pull_request: branches: - '**' + paths: + - 'src/components/**' + - 'tests/**' + - 'package.json' + - 'package-lock.json' + - '.github/workflows/test.yaml' jobs: publish: diff --git a/.gitignore b/.gitignore index 4fda35e..57c544d 100644 --- a/.gitignore +++ b/.gitignore @@ -28,9 +28,11 @@ next-env.d.ts # Storybook storybook-static +debug-storybook.log # build dist/ +.next # generated translations src/i18n/translations.ts \ No newline at end of file diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 46e6a33..148e05e 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -1,8 +1,7 @@ import type { Preview } from '@storybook/nextjs' import '../src/style/globals.css' import './storybookStyleOverrides.css' -import { ThemeProvider } from '../src/theming/useTheme' -import { LocaleProvider } from '../src/i18n/LocaleProvider' +import { HightideProvider } from '../src/contexts/HightideProvider' const preview: Preview = { parameters: { @@ -13,6 +12,12 @@ const preview: Preview = { system: { name: 'System', value: '#FFFFFF' }, } }, + docs: { + codePanel: true, + }, + options: { + selectedPanel: 'storybook/docs/panel', + }, }, globalTypes: { language: { @@ -33,15 +38,16 @@ const preview: Preview = { (Story, context) => { const App = Story const theme = context.globals.backgrounds?.value ?? 'system' - const language = context.globals.language + const locale = context.globals.language return (
- - - - - + + +
) }, diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cea756..fad0dff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,14 +5,52 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.6.0] - 2026-01-07 + +### Added + +- `HightideConfigContext` for storing Look and Feel Variables +- `HightideProvider` to bundle all hightide contexts in one provider +- Added Code panel to storybook +- `Drawer` component that alows for stacking +- autoprefixer for CSS backwards compatability +- `useTransitionState` hook to keep track of transition changes +- `Form` and `FormStore` for form handling +- `InifinteScroll` component for infinitly scrolling lists +- `PropertyField` type for enforcing the same interface for all properties + +### Changed + +- `Tables` to no longer round their column size +- Split `Expandable` into 3 components `ExpandableRoot`, `ExpandableHeader`, `ExpandableContent` +- storybook folder structure and removed title from stories to allow for dynamic folder structure +- Component styling to be CSS and data-attribute based instead of relying on in component classNames +- Moved `ExpansionIcon` out of `Expandable` file and into its own +- Changed internal folder structure +- Replaced `ZIndexRegistry` with `OverlayRegistry` to allow for saving more information about overlays then just their zIndex +- `FormElementWrapper` to clone children and give them the required props when not using the bag +- Input elements such as `Input`, `TextArea`, `Select`, etc. now all use `onValueChange` and `onEditComplete` instead of other alternative names +- Date- and Time-Picker to use the `value`, `onValueChange`, `onEditComplete` pattern + +### Fixed + +- `useOverwritableState` propagating the old instead of the updated state +- `Table` background to overflow on the edges + +### Removed + +- `Label` component due to it having no functionality + ## [0.5.4] - 2025-12-18 ### Fixed + - `useFloatingElement` calculating when no container exists yet ## [0.5.3] - 2025-12-18 ### Fixed + - `DateTimeInput` missing translation - a bug where changing days caused the minutes to change as well - the many `Tooltip`s story @@ -20,19 +58,23 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [0.5.2] - 2025-12-18 ### Fixed + - `LoadingAndErrorComponent` and `Visibility` to always return a `JSX.Element` ## [0.5.1] - 2025-12-18 ### Fixed + - fixed race condition in tooltip ## [0.5.0] - 2025-12-17 ### Added + - VisibilityComponent ### Changed + - Upgrade to Storybook 10.0.0 - Disable `Button`s `onClick` event propagation by default - This can be reactivated with the `allowClickEventPropagation` flag @@ -42,22 +84,27 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Changed Tooltip to be position based on anchor an not relative ### Fixed -- tooltips not disappearing if mouseleave happens too fast + +- tooltips not disappearing if mouseleave happens too fast - .arb variable typing not set ### Removed -- Tests for translation parser which are now in [@helpwave/hightide](https://github.com/helpwave/hightide) + +- Tests for translation parser which are now in [@helpwave/hightide](https://github.com/helpwave/hightide) ### Security + - Update dependencies ## [0.4.0] - 2025-12-16 ### Added + - A [conventions document](/documentation/conventions.md) - A `input-element` style for all user-input elements ### Changed + - All user-input elements now provide `data-disabled`, `data-invalid` and `data-value` attributes for styling - Focus styling now uses these tailwind utilities - `focus-style-outline` provided also as `focus-style-default` for every element (deactivatable with `focus-style-none`) @@ -69,20 +116,24 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [0.3.0] - 2025-12-15 ### Added + - `coloring-` CSS class uses the colors below to color the element according to the color - `prmiary`, `secondary`, `positive`, `negative`, `warning`, `disabled` CSS classes to set the variables for `coloring-*` - `useZIndexRegistry` hook to manage zIndex of elements that pop in and out of the viewport like `Select`, `Dialog`, `Tooltip` ### Changed + - Consolidated `SolidButton`, `TextButton`, `OutlinedButton`, `IconButton` into on component `Button` with four attributes `ButtonSize`, `ButtonColoringStyle`, `ButtonLayout`, `ButtonColor` -- Split css classes into theming and utility +- Split css classes into theming and utility - `SelectOption`s now only use marker padding when there is a selected value - Changed styling of `DayPicker` to make the currently selected day more easily visible ### Fixed + - Fixed `Carousel` having negative indexes for when left button is used ### Removed + - `SolidButton` - `TextButton` - `OutlinedButton` @@ -94,101 +145,123 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [0.2.0] - 2025-12-15 ### Added + - `PromiseUtils` with a `sleep` and `delayed` function ### Changed + - `CheckBox` now propagates value changes with `onCheckedChange` ### Removed -- Removed `row` and `col` css utilities use `flex-row-2` and `flex-col-2` instead - - Regex for checking usage ``("|'|`| )col("|'|`| )`` or ``("|'|`| )row("|'|`| )`` + +- Removed `row` and `col` css utilities use `flex-row-2` and `flex-col-2` instead + - Regex for checking usage `` ("|'|`| )col("|'|`| ) `` or `` ("|'|`| )row("|'|`| ) `` - Removed dependency on `radix-ui` ### Fixed + - Allow Tables to be sorted by multiple columns - Pagination max Page count now has the same size as the Input for the current page ### Security + - Update and pin all dependencies ## [0.1.48] - 2025-12-01 ### Added + - Added `TabView` and `Tab` for easily changing between tabs ### Update + - `"@helpwave/internationalization": "0.4.0"` ## [0.1.47] - 2025-11-24 ### Change + - `tsup` now only uses one entrypoint - Merged `build` and `build-production` into one `build` script ### Fix + - fix commonJS and module exports ## [0.1.46] - 2025-11-24 ### Fix + - Fix build to properly recognize external packages ### Security + - Remove unused dependencies ## [0.1.45] - 2025-11-24 ### Upgrade + - Increase version of `@helpwave/internationalization` to `0.3.0` ## [0.1.44] - 2025-11-23 ### Changed + - Change the name of the translations to `HightideTranslation` to be easier to integrate with other translations ## [0.1.43] - 2025-11-21 ### Changed + - Changed translation to use the arb format and ICU string replacements - Moved translations to the [locales folder](locales/) - Locales are now used instead of Languages - translations are now split into 2 translation functions - `useTranslation`: directly usable and uses the preferred locale - - `useICUTranslation`: parses every input with a ICU interpreter (less efficient) + - `useICUTranslation`: parses every input with a ICU interpreter (less efficient) ### Fixed + - fixed padding on the `InsideLabel` to be properly aligned ## [0.1.42] - 2025-10-31 ### Fixed + - Fixed `NavigationItemWithSubItem` to make all links have the same length int the menu - Fixed `
  • ` elements in `Navigation` ## [0.1.41] - 2025-10-31 ### Fixed + - Fixed `ThemeDialog` and `LanguageDialog` to properly show select options ## [0.1.40] - 2025-10-30 ### Fixed + - Fixed `Carousel` having more than one focusable item ## [0.1.39] - 2025-10-30 ### Changed + - Changed `Carousel` to have an event `onSlideChanged` when the slide changes ### Removed + - Removed `TextImage` component ## [0.1.38] - 2025-10-30 ### Changed + - Changed `Dialog`s to only be part of the DOM-Tree when open ### Fixed + - Fixed `SelectButton` not reacting correctly to arrow keys when determining the highlighted value - Fixed `ThemeProvider` and `LanguageProvider` to consider the `system` value as an overwrite - Fixed `ConfirmDialog` story using a wrong initial state @@ -196,101 +269,123 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [0.1.37] - 2025-10-30 ### Changed + - Exported and renamed `NavigationItem` to `NavigationItemType` ### Fixed + - Fixed `Dialog` not being client side by default ## [0.1.36] - 2025-10-06 ### Changed -- Changed `useLocalStorage` to remove values that produce an error on load + +- Changed `useLocalStorage` to remove values that produce an error on load ### Fixed + - Fixed closing animation for `Dialog` - Fixed `LanguageProvider` and `ThemeProvider` to not set undefined values into storage ## [0.1.35] - 2025-10-06 ### Added + - Added `--clean` option to barrel script - Added `useOverwritableState` to wrap `useState` and `useEffect` in uncontrolled components - Added `FormElementWrapper` onTouched callback and aria-attributes - Added `ValidatorError`s and translation for selection items ### Changed + - Changed `barrel` script location to a dedicated [scripts folder](scripts) - Split `build` script in `build` and `build-production` - Stopped saving of system theme and language values and instead load them if value is not found ### Removed + - Removed index.ts files from version control to force direct imports - Removed usages of `noop` ## [0.1.34] - 2025-10-03 ### Changed + - Changed Dialog z-index to 100 ### Fixed + - Fix `FormElementWrapper` labelledBy misspelling ## [0.1.33] - 2025-10-03 ### Changed + - Change `Dialog` to only use fixed positions ## [0.1.32] - 2025-10-03 ### Changed + - Changed the default background for surfaces and inputs to create higher contrasts ### Fixed + - Fix a `Dialog`s description rendering a div when not set - Fix initial misalignment of `Dialog`s in some cases ## [0.1.31] - 2025-10-03 ### Changed + - Make `SingleSelectProperty` and `MultiSelectProperty` use `SelectOption`s for styling ## [0.1.30] - 2025-10-03 ### Changed + - Changed `SingleSelectProperty` and `MultiSelectProperty` to accept a label ## [0.1.29] - 2025-10-02 ### Added + - HTML elements now use `color-scheme: dark` when in dark mode - Add invalid state styling to Selects - Add a placeholder color called `placeholder` - Add a hook for localized validation translation `useTranslatedValidators` ### Changed + - `disabled` and `required` are now optional in `FormElementWrapper` - changed focus to draw an outline instead of a ring ### Removed + - removed several typography entries that only change the `font-weight` (e.g. `typography-label-md-bold` -> `typography-label-md font-bold`) ### Fix + - Fix disabled color for `Select` ## [0.1.28] - 2025-10-02 ### Added + - added barreling script and barrel files ### Changed + - pin dependencies ### Fixed + - Fix css export path ## [0.1.27] - 2025-10-02 ### Added + - Add a theme preference listener to `useTheme` hook - Add icons to the Theme dialog - Add a config attribute to the `SelectRoot` component @@ -298,12 +393,14 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Add accessibility for carousel ### Changed + - move `isMultiSelect` attribute of `SelectRoot` into the config `SelectConfiguration` - split `layout-and-navigation` into `layout` and `navigation` (same for stories) ## [0.1.26] - 2025-09-24 ### Added + - Add `FloatingContainer` and `useFloatingElement` for aligning a floating element - Add `ListBox` for selecting a value from a list - Added accessibility for `Select`, `MultiSelect`, `Expandable`, `Avatar` @@ -314,6 +411,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Add `useIsMounted` for checking when a component is rendered ### Changed + - Renamed `textstyle` to `typography` for ClassNames - Changed css folder to style folder - Changed `HelpwaveBadge`, `HelpwaveLogo` to allow for different sizes @@ -324,6 +422,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Changed relative to absolute imports (only partial) ### Removed + - removed typographies (full list below) - `typography-title-3xl` - `typography-title-2xl` @@ -347,20 +446,24 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [0.1.25] - 2025-07-19 ### Added + - Added a story for a form example ### Fixed + - Fixed `ThemeProvider` to correctly use the stored theme in the context - Fixed disabled state and styling on `Checkbox`, `Input`, `Select`, `MultiSelect` ## [0.1.24] - 2025-07-17 ### Fixed -- Fixed `useTheme` and `ThemeProvider` to correctly load the theme + +- Fixed `useTheme` and `ThemeProvider` to correctly load the theme ## [0.1.23] - 2025-07-17 ### Changed + - Changed `Avatar` to show backup icon when no name or image supplied - Changed component to use next `Image` and `Link` - Changed CSS to use referential values @@ -371,44 +474,54 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [0.1.22] - 2025-07-16 ### Added + - Added `coloredHoverBackground` option to `TextButton` - Added colors for input elements to css instead of using surface ### Changed + - Change `Avatar` component to allow for name displays - Change color for `Modal`s and `Menu`s ### Fixed + - Fixed `Menu` and `Overlay` z-Indexes ## [0.1.21] - 2025-07-14 ### Added + - Save theme choice to local storage ### Changed + - Changed storybook background - Changed color on dark surface to be brighter ### Fixed + - Fixed `Menu` and `Overlay` z-Indexes ## [0.1.20] - 2025-07-14 ### Added + - Added `LoadingContainer` for showing a loading animation ### Changed + - Changed `Expandable` to allow for styling the expansion container ## [0.1.19] - 2025-07-11 ### Added + - Add animations for `Expandable`, `Select`, `MultiSelect`, `Menu`, `Modal`, `Dialog` -- Add utility for defining a flex-column or flex-row with its gap directly +- Add utility for defining a flex-column or flex-row with its gap directly - Add hook `usePopoverPosition` to easily define the position of a popover based on a trigger element ### Changed + - Changed the disabled color for dark and light mode - Changed overlay background color to be configurable - Changed `Select` and `MultiSelect` to now be variants of `Menu` @@ -419,34 +532,40 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [0.1.18] - 2025-07-07 ### Fix + - fix console logging in `useDelay` - fix helpwave icon animation for safari ## [0.1.17] - 2025-07-07 ### Fix + - fix `TableWithSelection` access to `bodyRowClassName` ## [0.1.16] - 2025-07-07 ### Added + - Added a `useDelay` story - Added a `CopyToClipboardWrapper` to allow for easy copy to clipboard buttons ### Changed -- `TableWithSelection` now allows for disabling row selection +- `TableWithSelection` now allows for disabling row selection ## [0.1.13] - 2025-07-04 ### Added + - Added a `TableCell` component which is used to display all unspecified table cells ### Changed + - `TableState` is now optional on the table ### Fixed -- Fixed `Table` stories to only use known properties + +- Fixed `Table` stories to only use known properties ## [0.1.12] - 2025-07-02 @@ -458,7 +577,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Added a page input to the pagination component - Added a `resolveSetState` function to easily get the value that result from a `SetState` in a `Dispatch` - Added `useRerender` hook to allow for easy rerendering -- Added `useFocusMangement`, `useFocusOnceVisible`. `useResizeCallbackWrapper` +- Added `useFocusMangement`, `useFocusOnceVisible`. `useResizeCallbackWrapper` ### Changed @@ -470,7 +589,6 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Fixed `CheckBox` disabled state - ## [0.1.11] - 2025-07-02 ### Changed @@ -599,6 +717,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Update the `Tooltip` appearance ### Removed + - examples which are now part of their corresponding story ## [Unreleased] diff --git a/documentation/conventions.md b/documentation/conventions.md deleted file mode 100644 index e3740b0..0000000 --- a/documentation/conventions.md +++ /dev/null @@ -1,111 +0,0 @@ -### Components -When designing components we follow the following principles: - -1. Separation of logic and styling -2. Same structure of components as described below - -### Component Structure -Here is an example how a good component looks like -```typescript jsx -// Define Props first -export type InputProps = InputHTMLAttributes & { - invalid?: boolean, - // Dont overwrite HTMLAttributes, always use a different name, even if its longer - onChangeText?: (text: string) => void, - editCompleteOptions?: EditCompleteOptions, -} - -// Give a documentation description -/** - * A Component for inputting text or other information - * - * Its state is managed must be managed by the parent - */ -export const Input = forwardRef(function Input({ - value, - onChange, - onChangeText, - onEditCompleted, - editCompleteOptions, - disabled = false, - invalid = false, - ...props -}, forwardedRef) { - // 1. States - // Exceptions are allowed when it is sematically more coherent to put it on top - // of the corresponding hook like for inner ref - - // 2. Constants and Memos - const { - onBlur: allowEditCompleteOnBlur, - afterDelay, - delay, - allowEnterComplete - } = { ...defaultEditCompleteOptions, ...editCompleteOptions } - - // 3. Other hooks and effects - const { - restartTimer, - clearTimer - } = useDelay({ delay, disabled: !afterDelay }) - - const innerRef = useRef(null) - useImperativeHandle(forwardedRef, () => innerRef.current) - - const { focusNext } = useFocusManagement() - - return ( - { - props.onKeyDown?.(event) - if (!allowEnterComplete) { - return - } - if (event.key === 'Enter' && !event.shiftKey) { - event.preventDefault() - innerRef.current?.blur() - onEditCompleted?.((event.target as HTMLInputElement).value) - focusNext() - } - }} - onBlur={event => { - props.onBlur?.(event) - if (allowEditCompleteOnBlur) { - onEditCompleted?.(event.target.value) - clearTimer() - } - }} - onChange={event => { - onChange?.(event) - const value = event.target.value - restartTimer(() => { - innerRef.current?.blur() - onEditCompleted?.(value) - }) - onChangeText?.(value) - }} - // 5. data-attributes - data-name={props['data-name'] ?? 'input'} - data-value={value ? '' : undefined} - data-disabled={disabled ? '' : undefined} - data-invalid={invalid ? '' : undefined} - - // 6. ARIA - aria-invalid={props['aria-invalid'] ?? invalid} - aria-disabled={props['aria-disabled'] ?? disabled} - - // 7. ClassName and style - /> - ) -}) -``` \ No newline at end of file diff --git a/eslint.config.js b/eslint.config.js index 1ae5b2b..d5f4e50 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -3,8 +3,8 @@ import config from '@helpwave/eslint-config' export default [ { - ignores: ['dist/**'], -}, + ignores: ['dist/**'], + }, ...config.recommended, ...storybook.configs['flat/recommended'], { @@ -15,5 +15,11 @@ export default [ }, { ignores: ['src/i18n/translations.ts'], + }, + { + // TODO add this to helpwave eslint config + rules: { + indent: ['warn', 2] + } } ] \ No newline at end of file diff --git a/locales/de-DE.arb b/locales/de-DE.arb index 4fedfe6..6ab5cfe 100644 --- a/locales/de-DE.arb +++ b/locales/de-DE.arb @@ -10,6 +10,8 @@ "chooseTheme": "Wähle dein bevorzugtes Farbschema.", "chooseSlide": "Wähle die angezeigte Slide aus", "clear": "Löschen", + "removeProperty": "Eigenschaft entfernen", + "clearValue": "Wert löschen", "click": "Klicken", "clickToCopy": "Zum kopieren klicken", "clickToSelect": "Zum Auswählen drücken", @@ -80,14 +82,14 @@ "submit": "Abschicken", "success": "Erfolg", "text": "Text", - "themes": "{count, plural, =1{Farbschema} other{Farbschemas}}", - "@themes": { + "pThemes": "{count, plural, =1{Farbschema} other{Farbschemas}}", + "@pThemes": { "placeholders": { "count": { "type": "number" } } }, - "themeMode": "{theme, select, dark{Dunkel} light{Hell} system{System}}", - "@themeMode": { + "sThemeMode": "{theme, select, dark{Dunkel} light{Hell} system{System}}", + "@sThemeMode": { "placeholders": { "theme": {} } @@ -97,11 +99,11 @@ "unsavedChangesSaveQuestion": "Möchtest du die Änderungen speichern?", "value": "Wert", "yes": "Ja", + "endDate": "Ende", "filter": "Filter", - "min": "Min", "max": "Max", + "min": "Min", "startDate": "Start", - "endDate": "Ende", "notEmpty": "Das Feld darf nicht leer sein.", "invalidEmail": "Die E-Mail ist ungültig.", "tooShort": "Der Wert muss mindestens {min} Zeichen enthalten.", @@ -149,19 +151,19 @@ "max": { "type": "number" } } }, + "age": "Alter", + "entryDate": "Eintragsdatum", "identifier": "Identifikator", "name": "Name", - "age": "Alter", "street": "Straße", - "entryDate": "Eintragsdatum", - "gender": "{gender, select, male{Männlich} female{Weiblich} other{Divers}}", - "@gender": { + "sGender": "{gender, select, male{Männlich} female{Weiblich} other{Divers}}", + "@sGender": { "placeholders": { "gender": {} } }, - "welcome": "Willkommen", "goodToSeeYou": "Schön dich zu sehen", + "welcome": "Willkommen", "rSortingOrderAfter": "Angewendet {otherSortings, plural, =0{als primäre Sortierung} =1{nach # anderen Sortierung} other{nach # anderen Sortierungen}}", "@rSortingOrderAfter": { "placeholders": { diff --git a/locales/en-US.arb b/locales/en-US.arb index 62863a7..d5654e1 100644 --- a/locales/en-US.arb +++ b/locales/en-US.arb @@ -10,6 +10,8 @@ "chooseTheme": "Choose your preferred color theme.", "chooseSlide": "Choose slide to display", "clear": "Clear", + "removeProperty": "Remove Property", + "clearValue": "Clear Value", "click": "Click", "clickToCopy": "Click to Copy", "clickToSelect": "Click to select", @@ -80,14 +82,14 @@ "submit": "Submit", "success": "Success", "text": "Text", - "themes": "{count, plural, =1{Theme} other{Themes}}", - "@themes": { + "pThemes": "{count, plural, =1{Theme} other{Themes}}", + "@pThemes": { "placeholders": { "count": { "type": "number" } } }, - "themeMode": "{theme, select, dark{Dark} light{Light} system{System}}", - "@themeMode": { + "sThemeMode": "{theme, select, dark{Dark} light{Light} system{System}}", + "@sThemeMode": { "placeholders": { "theme": {} } @@ -97,11 +99,11 @@ "unsavedChangesSaveQuestion": "Do you want to save your changes?", "value": "Value", "yes": "Yes", + "endDate": "End", "filter": "Filter", - "min": "Min", "max": "Max", + "min": "Min", "startDate": "Start", - "endDate": "End", "notEmpty": "The field cannot be empty.", "invalidEmail": "The email is not valid.", "tooShort": "The value requires at least {min} characters.", @@ -149,19 +151,19 @@ "max": { "type": "number" } } }, + "age": "Age", + "entryDate": "Entry Date", "identifier": "Identifier", "name": "Name", - "age": "Age", "street": "Street", - "entryDate": "Entry Date", - "gender": "{gender, select, male{Male} female{Female} other{Other}}", - "@gender": { + "sGender": "{gender, select, male{Male} female{Female} other{Other}}", + "@sGender": { "placeholders": { "gender": {} } }, - "welcome": "Welcome", "goodToSeeYou": "Good to see you", + "welcome": "Welcome", "rSortingOrderAfter": "Applied {otherSortings, plural, =0{as the first sorting} =1{after # other sorting} other{after # other sortings}}", "@rSortingOrderAfter": { "placeholders": { diff --git a/locales/time/de-DE.arb b/locales/time/de-DE.arb index 96e17a7..9ac8c38 100644 --- a/locales/time/de-DE.arb +++ b/locales/time/de-DE.arb @@ -120,8 +120,8 @@ } } }, - "monthName": "{month, select, january{Januar} february{Februar} march{März} april{April} may{Mai} june{Juni} july{Juli} august{August} september{September} october{Oktober} november{November} december{Dezember} other{Unbekannter Monat}}", - "@monthName": { + "sMonthName": "{month, select, january{Januar} february{Februar} march{März} april{April} may{Mai} june{Juni} july{Juli} august{August} september{September} october{Oktober} november{November} december{Dezember} other{Unbekannter Monat}}", + "@sMonthName": { "placeholders": { "month": {} } diff --git a/locales/time/en-US.arb b/locales/time/en-US.arb index 4f2b434..2663712 100644 --- a/locales/time/en-US.arb +++ b/locales/time/en-US.arb @@ -120,8 +120,8 @@ } } }, - "monthName": "{month, select, january{January} february{February} march{March} april{April} may{May} june{June} july{July} august{August} september{September} october{October} november{November} december{December} other{Unknown Month}}", - "@monthName": { + "sMonthName": "{month, select, january{January} february{February} march{March} april{April} may{May} june{June} july{July} august{August} september{September} october{October} november{November} december{December} other{Unknown Month}}", + "@sMonthName": { "placeholders": { "month": {} } diff --git a/package-lock.json b/package-lock.json index 9393fa0..8b61c77 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@helpwave/hightide", - "version": "0.5.0", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@helpwave/hightide", - "version": "0.5.0", + "version": "0.6.0", "license": "MPL-2.0", "dependencies": { "@helpwave/internationalization": "0.4.0", @@ -14,7 +14,6 @@ "@tanstack/react-table": "8.21.3", "clsx": "2.1.1", "lucide-react": "0.561.0", - "postcss": "8.5.3", "react": "19.2.3", "react-dom": "19.2.3", "tailwindcss": "4.1.18" @@ -39,10 +38,12 @@ "@types/react-dom": "19.2.3", "@types/tinycolor2": "1.4.6", "@vitest/mocker": "^4.0.16", + "autoprefixer": "^10.4.23", "eslint": "9.31.0", "eslint-plugin-storybook": "10.1.9", "jest": "30.2.0", - "storybook": "10.1.9", + "postcss": "^8.5.6", + "storybook": "10.1.10", "ts-jest": "29.4.5", "tsup": "8.5.0", "typescript": "5.7.2", @@ -7366,6 +7367,43 @@ "node": ">= 0.4" } }, + "node_modules/autoprefixer": { + "version": "10.4.23", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz", + "integrity": "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001760", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -10158,6 +10196,20 @@ "node": ">=10" } }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, "node_modules/fs-extra": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", @@ -13536,6 +13588,7 @@ "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, "funding": [ { "type": "github", @@ -14418,9 +14471,10 @@ } }, "node_modules/postcss": { - "version": "8.5.3", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", - "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, "funding": [ { "type": "opencollective", @@ -14437,7 +14491,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.8", + "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -14748,9 +14802,9 @@ "license": "MIT" }, "node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -15872,9 +15926,9 @@ "license": "MIT" }, "node_modules/storybook": { - "version": "10.1.9", - "resolved": "https://registry.npmjs.org/storybook/-/storybook-10.1.9.tgz", - "integrity": "sha512-gHW/jOxLNzVw/Ys1XJovgrMFyh37ftMsLIw0l0h4fLsEyXhUABwrgjDp5bWrUmbQqemAIYVAAtw7UjPEdcHgkA==", + "version": "10.1.10", + "resolved": "https://registry.npmjs.org/storybook/-/storybook-10.1.10.tgz", + "integrity": "sha512-oK0t0jEogiKKfv5Z1ao4Of99+xWw1TMUGuGRYDQS4kp2yyBsJQEgu7NI7OLYsCDI6gzt5p3RPtl1lqdeVLUi8A==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index f88e45b..5de6c85 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "url": "git+https://github.com/helpwave/hightide.git" }, "license": "MPL-2.0", - "version": "0.5.4", + "version": "0.6.0", "files": [ "dist" ], @@ -43,7 +43,6 @@ "@tanstack/react-table": "8.21.3", "clsx": "2.1.1", "lucide-react": "0.561.0", - "postcss": "8.5.3", "react": "19.2.3", "react-dom": "19.2.3", "tailwindcss": "4.1.18" @@ -65,10 +64,12 @@ "@types/react-dom": "19.2.3", "@types/tinycolor2": "1.4.6", "@vitest/mocker": "^4.0.16", + "autoprefixer": "^10.4.23", "eslint": "9.31.0", "eslint-plugin-storybook": "10.1.9", "jest": "30.2.0", - "storybook": "10.1.9", + "postcss": "^8.5.6", + "storybook": "10.1.10", "ts-jest": "29.4.5", "tsup": "8.5.0", "typescript": "5.7.2", diff --git a/postcss.config.mjs b/postcss.config.mjs index c8e889a..478530e 100644 --- a/postcss.config.mjs +++ b/postcss.config.mjs @@ -1,6 +1,7 @@ const config = { plugins: { '@tailwindcss/postcss': {}, + 'autoprefixer': {}, } } diff --git a/src/components/branding/HelpwaveBadge.tsx b/src/components/branding/HelpwaveBadge.tsx index e61e976..b7a2d2f 100644 --- a/src/components/branding/HelpwaveBadge.tsx +++ b/src/components/branding/HelpwaveBadge.tsx @@ -1,4 +1,4 @@ -import { HelpwaveLogo } from '../icons-and-geometry/HelpwaveLogo' +import { HelpwaveLogo } from './HelpwaveLogo' import type { HTMLAttributes } from 'react' import clsx from 'clsx' @@ -12,9 +12,9 @@ export type HelpwaveBadgeProps = HTMLAttributes & { * A Badge with the helpwave logo and the helpwave name */ export const HelpwaveBadge = ({ - size = 'sm', - ...props - }: HelpwaveBadgeProps) => { + size = 'sm', + ...props +}: HelpwaveBadgeProps) => { return ( & { * The helpwave loading spinner based on the svg logo. */ export const HelpwaveLogo = ({ - color = 'currentColor', - size, - animate = 'none', - ...props - }: HelpwaveProps) => { + color = 'currentColor', + size, + animate = 'none', + ...props +}: HelpwaveProps) => { const isLoadingAnimation = animate === 'loading' let svgAnimationKey = '' @@ -40,16 +40,16 @@ export const HelpwaveLogo = ({ > + d="M146 644.214C146 498.088 253.381 379.629 385.843 379.629" stroke={color} strokeDasharray="1000"/> + d="M625.686 645.272C493.224 645.272 385.843 526.813 385.843 380.687" stroke={color} + strokeDasharray="1000"/> + d="M533.585 613.522C533.585 508.895 610.47 424.079 705.312 424.079" stroke={color} + strokeDasharray="1000"/> + d="M878 615.639C782.628 615.639 705.313 530.822 705.313 426.196" stroke={color} + strokeDasharray="1000"/> ) diff --git a/src/components/date/DayPicker.tsx b/src/components/date/DayPicker.tsx deleted file mode 100644 index a400893..0000000 --- a/src/components/date/DayPicker.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import type { WeekDay } from '@/src/utils/date' -import { DateUtils, getWeeksForCalenderMonth, isInTimeSpan } from '@/src/utils/date' -import clsx from 'clsx' -import { useLocale } from '@/src/i18n/LocaleProvider' -import { useEffect, useState } from 'react' -import { Button } from '@/src/components/user-action/Button' - -export type DayPickerProps = { - displayedMonth: Date, - selected?: Date, - start?: Date, - end?: Date, - onChange?: (date: Date) => void, - weekStart?: WeekDay, - markToday?: boolean, - className?: string, -} - -/** - * A component for selecting a day of a month - */ -export const DayPicker = ({ - displayedMonth, - selected, - start, - end, - onChange, - weekStart = 'monday', - markToday = true, - className = '' - }: DayPickerProps) => { - const { locale } = useLocale() - const month = displayedMonth.getMonth() - const weeks = getWeeksForCalenderMonth(displayedMonth, weekStart) - - return ( -
    -
    - {weeks[0]!.map((weekDay, index) => ( -
    - {new Intl.DateTimeFormat(locale, { weekday: 'long' }).format(weekDay).substring(0, 2)} -
    - ))} -
    - {weeks.map((week, index) => ( -
    - {week.map((date) => { - const isSelected = !!selected && DateUtils.equalDate(selected, date) - const isToday = DateUtils.equalDate(new Date(), date) - const isSameMonth = date.getMonth() === month - const isDayValid = isInTimeSpan(date, start, end) - return ( - - ) - })} -
    - ))} -
    - ) -} - -export const DayPickerUncontrolled = ({ - displayedMonth, - selected, - onChange, - ...restProps - }: DayPickerProps) => { - const [date, setDate] = useState(selected) - const [usedDisplayedMonth, setUsedDDisplayedMonth] = useState(displayedMonth) - - useEffect(() => { - setDate(selected) - setUsedDDisplayedMonth(selected) - }, [selected]) - - return ( - { - setDate(newDate) - setUsedDDisplayedMonth(newDate) - onChange?.(newDate) - }} - {...restProps} - /> - ) -} diff --git a/src/components/date/TimePicker.tsx b/src/components/date/TimePicker.tsx deleted file mode 100644 index 41b1162..0000000 --- a/src/components/date/TimePicker.tsx +++ /dev/null @@ -1,160 +0,0 @@ -import { useEffect, useRef } from 'react' -import { closestMatch, range } from '@/src/utils/array' -import clsx from 'clsx' -import { useOverwritableState } from '@/src/hooks/useOverwritableState' -import { Button } from '@/src/components/user-action/Button' - -type MinuteIncrement = '1min' | '5min' | '10min' | '15min' | '30min' - -export type TimePickerProps = { - time?: Date, - onChange?: (time: Date) => void, - is24HourFormat?: boolean, - minuteIncrement?: MinuteIncrement, - maxHeight?: number, - className?: string, -} - -export const TimePicker = ({ - time = new Date(), - onChange, - is24HourFormat = true, - minuteIncrement = '5min', - maxHeight = 280, - className = '' - }: TimePickerProps) => { - const minuteRef = useRef(null) - const hourRef = useRef(null) - - const isPM = time.getHours() >= 11 - const hours = is24HourFormat ? range(24) : range(12) - let minutes = range(60) - - useEffect(() => { - const scrollToItem = () => { - if (minuteRef.current) { - const container = minuteRef.current.parentElement! - - const hasOverflow = container.scrollHeight > maxHeight - if (hasOverflow) { - minuteRef.current.scrollIntoView({ - behavior: 'instant', - block: 'nearest', - }) - } - } - } - scrollToItem() - }, [minuteRef, minuteRef.current]) // eslint-disable-line - - useEffect(() => { - const scrollToItem = () => { - if (hourRef.current) { - const container = hourRef.current.parentElement! - - const hasOverflow = container.scrollHeight > maxHeight - if (hasOverflow) { - hourRef.current.scrollIntoView({ - behavior: 'instant', - block: 'nearest', - }) - } - } - } - scrollToItem() - }, [hourRef, hourRef.current]) // eslint-disable-line - - switch (minuteIncrement) { - case '5min': - minutes = minutes.filter(value => value % 5 === 0) - break - case '10min': - minutes = minutes.filter(value => value % 10 === 0) - break - case '15min': - minutes = minutes.filter(value => value % 15 === 0) - break - case '30min': - minutes = minutes.filter(value => value % 30 === 0) - break - } - - const closestMinute = closestMatch(minutes, (item1, item2) => Math.abs(item1 - time.getMinutes()) < Math.abs(item2 - time.getMinutes())) - - const onChangeWrapper = (transformer: (newDate: Date) => void) => { - const newDate = new Date(time) - transformer(newDate) - onChange?.(newDate) - } - - return ( -
    -
    - {hours.map(hour => { - const isSelected = hour === time.getHours() - (!is24HourFormat && isPM ? 12 : 0) - return ( - - ) - })} -
    -
    - {minutes.map(minute => { - const isSelected = minute === closestMinute - return ( - - ) - })} -
    - {!is24HourFormat && ( -
    - - -
    - )} -
    - ) -} - -export const TimePickerUncontrolled = ({ - time, - onChange, - ...props - }: TimePickerProps) => { - const [value, setValue] = useOverwritableState(time, onChange) - - return ( - - ) -} diff --git a/src/components/date/YearMonthPicker.tsx b/src/components/date/YearMonthPicker.tsx deleted file mode 100644 index a7fbd98..0000000 --- a/src/components/date/YearMonthPicker.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import { useEffect, useRef } from 'react' -import { equalSizeGroups, range } from '@/src/utils/array' -import clsx from 'clsx' -import { ExpandableUncontrolled } from '@/src/components/layout/Expandable' -import { addDuration, DateUtils, subtractDuration } from '@/src/utils/date' -import { useLocale } from '@/src/i18n/LocaleProvider' -import { Button } from '../user-action/Button' -import { useOverwritableState } from '@/src/hooks/useOverwritableState' - -export type YearMonthPickerProps = { - displayedYearMonth?: Date, - start?: Date, - end?: Date, - onChange?: (date: Date) => void, - className?: string, - maxHeight?: number, - showValueOpen?: boolean, -} - -export const YearMonthPicker = ({ - displayedYearMonth = new Date(), - start = subtractDuration(new Date(), { years: 50 }), - end = addDuration(new Date(), { years: 50 }), - onChange, - className = '', - showValueOpen = true - }: YearMonthPickerProps) => { - const { locale } = useLocale() - const ref = useRef(null) - - useEffect(() => { - const scrollToItem = () => { - if (ref.current) { - ref.current.scrollIntoView({ - behavior: 'instant', - block: 'center', - }) - } - } - - scrollToItem() - }, [ref]) - - if (end < start) { - console.error(`startYear: (${start}) less than endYear: (${end})`) - return null - } - - const years = range([start.getFullYear(), end.getFullYear()], { exclusiveEnd: false }) - - return ( -
    - {years.map(year => { - const selectedYear = displayedYearMonth.getFullYear() === year - return ( - {year}} - isExpanded={showValueOpen && selectedYear} - contentClassName="gap-y-1" - contentExpandedClassName="!p-2" - > - {equalSizeGroups([...DateUtils.monthsList], 3).map((monthList, index) => ( -
    - {monthList.map(month => { - const monthIndex = DateUtils.monthsList.indexOf(month) - const newDate = new Date(year, monthIndex) - - const selectedMonth = selectedYear && monthIndex === displayedYearMonth.getMonth() - const firstOfMonth = new Date(year, monthIndex, 1) - const lastOfMonth = new Date(year, monthIndex, 1) - const isAfterStart = start === undefined || start <= addDuration(subtractDuration(lastOfMonth, { days: 1 }), { months: 1 }) - const isBeforeEnd = end === undefined || firstOfMonth <= end - const isValid = isAfterStart && isBeforeEnd - return ( - - ) - })} -
    - ))} -
    - ) - })} -
    - ) -} - -export const YearMonthPickerUncontrolled = ({ - displayedYearMonth, - onChange, - ...props - }: YearMonthPickerProps) => { - const [yearMonth, setYearMonth] = useOverwritableState(displayedYearMonth, onChange) - - return ( - - ) -} diff --git a/src/components/dialog/Dialog.tsx b/src/components/dialog/Dialog.tsx deleted file mode 100644 index 2db62e1..0000000 --- a/src/components/dialog/Dialog.tsx +++ /dev/null @@ -1,174 +0,0 @@ -'use client' - -import type { HTMLAttributes, PropsWithChildren, ReactNode } from 'react' -import { useId } from 'react' -import { useEffect, useRef, useState } from 'react' -import clsx from 'clsx' -import { X } from 'lucide-react' -import { useHightideTranslation } from '@/src/i18n/useHightideTranslation' -import { Button } from '@/src/components/user-action/Button' -import { useFocusTrap } from '@/src/hooks/focus/useFocusTrap' -import { useLogOnce } from '@/src/hooks/useLogOnce' -import { createPortal } from 'react-dom' -import { useZIndexRegister } from '@/src/hooks/useZIndexRegister' - -export type DialogPosition = 'top' | 'center' | 'none' - -export type DialogProps = HTMLAttributes & { - /** Whether the dialog is currently open */ - isOpen: boolean, - /** Title of the Dialog used for accessibility */ - titleElement: ReactNode, - /** Description of the Dialog used for accessibility */ - description: ReactNode, - /** Callback when the dialog tries to close */ - onClose?: () => void, - /** Styling for the background */ - backgroundClassName?: string, - /** If true shows a close button and sends onClose on background clicks */ - isModal?: boolean, - position?: DialogPosition, - isAnimated?: boolean, - containerClassName?: string, -} - -/** - * A generic dialog window which is managed by its parent - */ -export const Dialog = ({ - children, - isOpen, - titleElement, - description, - isModal = true, - onClose, - backgroundClassName, - position = 'center', - isAnimated = true, - containerClassName, - ...props - }: PropsWithChildren) => { - const translation = useHightideTranslation() - const [visible, setVisible] = useState(isOpen) - const generatedId = useId() - const id = props.id ?? generatedId - - const ref = useRef(null) - - useEffect(() => { - if (isOpen) { - setVisible(true) - } else { - if (!isAnimated) { - setVisible(false) - } - } - }, [isAnimated, isOpen]) - - const onCloseWrapper = () => { - if (!isModal) return - onClose?.() - } - - useLogOnce('Dialog: onClose should be defined for modal dialogs', isModal && !onClose) - - useFocusTrap({ - container: ref, - active: visible, - focusFirst: true, - }) - - const zIndex = useZIndexRegister(isOpen) - - const positionMap: Record = { - top: 'fixed top-8 left-1/2 -translate-x-1/2', - center: 'fixed left-1/2 -translate-x-1/2 top-1/2 -translate-y-1/2', - none: '' - } - const positionStyle = positionMap[position] - - if (!visible) return - - return createPortal( -
    - - , document.body - ) -} \ No newline at end of file diff --git a/src/components/dialog/InputDialog.tsx b/src/components/dialog/InputDialog.tsx deleted file mode 100644 index 7592893..0000000 --- a/src/components/dialog/InputDialog.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import type { InputProps } from '../user-action/input/Input' -import { Input } from '../user-action/input/Input' -import type { ConfirmDialogProps } from '@/src/components/dialog/ConfirmDialog' -import { ConfirmDialog } from '@/src/components/dialog/ConfirmDialog' - -export type InputModalProps = ConfirmDialogProps & { - inputs: InputProps[], -} - -/** - * A modal for receiving multiple inputs - */ -export const InputDialog = ({ - inputs, - buttonOverwrites, - ...props - }: InputModalProps) => { - return ( - - {inputs.map((inputProps, index) => )} - - ) -} diff --git a/src/components/display-and-visualization/Avatar.tsx b/src/components/display-and-visualization/Avatar.tsx new file mode 100644 index 0000000..6336d34 --- /dev/null +++ b/src/components/display-and-visualization/Avatar.tsx @@ -0,0 +1,128 @@ +import type { HTMLAttributes } from 'react' +import { useEffect, useMemo, useState } from 'react' +import { UserIcon } from 'lucide-react' +import { Visibility } from '../layout/Visibility' + +export type AvatarSize = 'xs' | 'sm' | 'md' | 'lg' | null + +type ImageConfig = { + avatarUrl: string, + alt: string, +} + + +export type AvatarProps = Omit, 'children'> & { + image?: ImageConfig, + name?: string, + size?: AvatarSize, +} + +/** + * A component for showing a profile picture + */ +export const Avatar = ({ + image: initialImage, + name, + size = 'md', + ...props +}: AvatarProps) => { + const [hasError, setHasError] = useState(false) + const [hasLoaded, setHasLoaded] = useState(false) + const [image, setImage] = useState(initialImage) + + const displayName = useMemo(() => { + const maxLetters = size === 'sm' ? 1 : 2 + return (name ?? '') + .split(' ') + .filter((_, index) => index < maxLetters) + .map(value => value[0]) + .join('') + .toUpperCase() + }, [name, size]) + + const isShowingImage = !!image && (!hasError || !hasLoaded) + const dataName = props['data-name'] ?? 'avatar' + + useEffect(() => { + if(initialImage?.avatarUrl !== image?.avatarUrl) { + setHasError(false) + setHasLoaded(false) + } + setImage(initialImage) + }, [image?.avatarUrl, initialImage]) + + return ( +
    + + {image?.alt} setHasLoaded(true)} + onError={() => setHasError(true)} + + data-name={`${dataName}-image`} + data-error={hasError ? '' : undefined} + data-loaded={hasLoaded ? '' : undefined} + /> + + {name ? displayName : ()} +
    + ) +} + +export type AvatarGroupProps = HTMLAttributes & { + avatars: Omit[], + showTotalNumber?: boolean, + size?: AvatarSize, +} + +/** + * A component for showing a group of Avatar's + */ +export const AvatarGroup = ({ + avatars, + showTotalNumber = true, + size = 'md', + ...props +}: AvatarGroupProps) => { + const maxShownProfiles = 5 + const displayedProfiles = avatars.length < maxShownProfiles ? avatars : avatars.slice(0, maxShownProfiles) + const notDisplayedProfiles = avatars.length - maxShownProfiles + const group = ( +
    + {displayedProfiles.map((avatar, index) => ( + + ))} +
    + ) + + + return ( +
    + {group} + {showTotalNumber && notDisplayedProfiles > 0 && ( + + {`+ ${notDisplayedProfiles}`} + + )} +
    + ) +} \ No newline at end of file diff --git a/src/components/display-and-visualization/Chip.tsx b/src/components/display-and-visualization/Chip.tsx new file mode 100644 index 0000000..6e2b4e6 --- /dev/null +++ b/src/components/display-and-visualization/Chip.tsx @@ -0,0 +1,70 @@ +import type { HTMLAttributes } from 'react' +import { ButtonUtil } from '@/src/components/user-interaction/Button' + +type ChipSize = 'xs' | 'sm' | 'md' | 'lg' | null + +type ChipColoringStyle = 'solid' | 'tonal' | null + +const chipColors = ButtonUtil.colors +export type ChipColor = typeof chipColors[number] + +export const ChipUtil = { + colors: chipColors, +} + +export type ChipProps = HTMLAttributes & { + color?: ChipColor, + coloringStyle?: ChipColoringStyle, + size?: ChipSize, +} + +/** + * A component for displaying a single chip + */ +export const Chip = ({ + children, + color = 'neutral', + coloringStyle = 'solid', + size = 'md', + ...props +}: ChipProps) => { + return ( +
    + {children} +
    + ) +} + +export type ChipListProps = HTMLAttributes & { + list: ChipProps[], +} + +/** + * A component for displaying a list of chips + */ +export const ChipList = ({ + list, + ...props +}: ChipListProps) => { + return ( +
      + {list.map((value, index) => ( +
    • + + {value.children} + +
    • + ))} +
    + ) +} diff --git a/src/components/icons-and-geometry/Circle.tsx b/src/components/display-and-visualization/Circle.tsx similarity index 69% rename from src/components/icons-and-geometry/Circle.tsx rename to src/components/display-and-visualization/Circle.tsx index 95c7f84..2a811a4 100644 --- a/src/components/icons-and-geometry/Circle.tsx +++ b/src/components/display-and-visualization/Circle.tsx @@ -7,11 +7,11 @@ export type CircleProps = Omit, 'children' | 'col } export const Circle = ({ - radius = 20, - className = 'bg-primary', - style, - ...restProps - }: CircleProps) => { + radius = 20, + className = 'bg-primary', + style, + ...restProps +}: CircleProps) => { const size = radius * 2 return (
    & { + isExpanded: boolean, + disabled?: boolean, +} + +export const ExpansionIcon = ({ + children, + isExpanded, + disabled = false, + ...props +}: ExpansionIconProps) => { + + return ( +
    + {children ? ( + children + ) : ( + + )} +
    + ) +} \ No newline at end of file diff --git a/src/components/loading-states/ProgressIndicator.tsx b/src/components/display-and-visualization/ProgressIndicator.tsx similarity index 71% rename from src/components/loading-states/ProgressIndicator.tsx rename to src/components/display-and-visualization/ProgressIndicator.tsx index 7d7355b..eebe4d1 100644 --- a/src/components/loading-states/ProgressIndicator.tsx +++ b/src/components/display-and-visualization/ProgressIndicator.tsx @@ -25,12 +25,12 @@ const sizeMapping = { small: 16, medium: 24, big: 48 } * Progress is given from 0 to 1 */ export const ProgressIndicator = ({ - progress, - strokeWidth = 5, - size = 'medium', - direction = 'counterclockwise', - rotation = 0 - }: ProgressIndicatorProps) => { + progress, + strokeWidth = 5, + size = 'medium', + direction = 'counterclockwise', + rotation = 0 +}: ProgressIndicatorProps) => { const currentSize = sizeMapping[size] const center = currentSize / 2 const radius = center - strokeWidth / 2 @@ -48,10 +48,10 @@ export const ProgressIndicator = ({ }} > ) diff --git a/src/components/icons-and-geometry/Ring.tsx b/src/components/display-and-visualization/Ring.tsx similarity index 82% rename from src/components/icons-and-geometry/Ring.tsx rename to src/components/display-and-visualization/Ring.tsx index 00cc2eb..8e3716b 100644 --- a/src/components/icons-and-geometry/Ring.tsx +++ b/src/components/display-and-visualization/Ring.tsx @@ -10,10 +10,10 @@ export type RingProps = { }; export const Ring = ({ - innerSize = 20, - width = 7, - className = 'outline-primary', - }: RingProps) => { + innerSize = 20, + width = 7, + className = 'outline-primary', +}: RingProps) => { return (
    { + innerSize, + width, + className, + fillAnimationDuration = 3, + repeating = false, + onAnimationFinished, + style, +}: AnimatedRingProps) => { const [currentWidth, setCurrentWidth] = useState(0) const milliseconds = 1000 * fillAnimationDuration @@ -93,15 +93,15 @@ export type RingWaveProps = Omit & { }; export const RingWave = ({ - startInnerSize = 20, - endInnerSize = 30, - width, - className, - fillAnimationDuration = 3, - repeating = false, - onAnimationFinished, - style - }: RingWaveProps) => { + startInnerSize = 20, + endInnerSize = 30, + width, + className, + fillAnimationDuration = 3, + repeating = false, + onAnimationFinished, + style +}: RingWaveProps) => { const [currentInnerSize, setCurrentInnerSize] = useState(startInnerSize) const distance = endInnerSize - startInnerSize const milliseconds = 1000 * fillAnimationDuration @@ -163,15 +163,15 @@ export type RadialRingsProps = { // TODO use fixed colors here to avoid artifacts export const RadialRings = ({ - circle1ClassName = 'bg-primary/90 outline-primary/90', - circle2ClassName = 'bg-primary/60 outline-primary/60', - circle3ClassName = 'bg-primary/40 outline-primary/40', - waveWidth = 10, - waveBaseColor = 'outline-white/20', - sizeCircle1 = 100, - sizeCircle2 = 200, - sizeCircle3 = 300 - }: RadialRingsProps) => { + circle1ClassName = 'bg-primary/90 outline-primary/90', + circle2ClassName = 'bg-primary/60 outline-primary/60', + circle3ClassName = 'bg-primary/40 outline-primary/40', + waveWidth = 10, + waveBaseColor = 'outline-white/20', + sizeCircle1 = 100, + sizeCircle2 = 200, + sizeCircle3 = 300 +}: RadialRingsProps) => { const [currentRing, setCurrentRing] = useState(0) const size = sizeCircle3 diff --git a/src/components/icons-and-geometry/Tag.tsx b/src/components/display-and-visualization/Tag.tsx similarity index 83% rename from src/components/icons-and-geometry/Tag.tsx rename to src/components/display-and-visualization/Tag.tsx index 1dad43b..22014e0 100644 --- a/src/components/icons-and-geometry/Tag.tsx +++ b/src/components/display-and-visualization/Tag.tsx @@ -13,9 +13,9 @@ export type TagProps = { * When using it make attribution */ export const TagIcon = ({ - className, - size = 16, - }: TagProps) => { + className, + size = 16, +}: TagProps) => { return ( , 'aria-labelledby' | 'aria-describedby' | 'aria-disabled' | 'aria-readonly' | 'aria-invalid' | 'aria-errormessage' | 'aria-required'> + +export type FormFieldInteractionStates = { + invalid: boolean, + disabled: boolean, + readOnly: boolean, + required: boolean, +} + +export type FormFieldLayoutIds = { + input: string, + error: string, + label: string, + description: string, +} + +export type FormFieldLayoutBag = { + interactionStates: FormFieldInteractionStates, + ariaAttributes: FormFieldAriaAttributes, + id: string, +} + +export type FormFieldLayoutProps = Omit, 'children'> & Omit, 'invalid'> & { + children: (bag: FormFieldLayoutBag) => ReactNode, + ids?: Partial, + label?: ReactNode, + labelProps?: Omit, 'children' | 'id'>, + invalidDescription?: ReactNode, + invalidDescriptionProps?: Omit, 'children' | 'id'>, + description?: ReactNode, + descriptionProps?: Omit, 'children' | 'id'>, +} + +export const FormFieldLayout = forwardRef(function FormFieldLayout({ + children, + ids: idOverwrites = {}, + required = false, + disabled = false, + readOnly = false, + label, + labelProps, + invalidDescription, + invalidDescriptionProps, + description, + descriptionProps, + ...props +}, ref) { + const generatedId = useId() + + const ids = useMemo(() => ({ + input: idOverwrites.input ?? `form-field-input-${generatedId}`, + error: idOverwrites.error ?? `form-field-error-${generatedId}`, + label: idOverwrites.label ?? `form-field-label-${generatedId}`, + description: idOverwrites.description ?? `form-field-description-${generatedId}` + }), [generatedId, idOverwrites]) + + const describedBy: string = [ + description ? ids.description : undefined, + invalidDescription ? ids.error : undefined, + ].filter(Boolean).join(' ') + + const invalid = !!invalidDescription + + const inputAriaAttributes: FormFieldAriaAttributes = useMemo(() => ({ + 'aria-required': required, + 'aria-describedby': describedBy, + 'aria-labelledby': label ? ids.label : undefined, + 'aria-invalid': invalid, + 'aria-errormessage': invalid ? ids.error : undefined, + }), [describedBy, ids.error, ids.label, invalid, label, required]) + + const state: FormFieldInteractionStates = useMemo(() => ({ + disabled, + invalid, + readOnly, + required, + }), [disabled, invalid, readOnly, required]) + + const bag: FormFieldLayoutBag = { + interactionStates: state, + ariaAttributes: inputAriaAttributes, + id: ids.input, + } + + return ( +
    + {label && ( +