diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..c50c1675 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "coding-conventions"] + path = coding-conventions + url = https://github.com/helpwave/coding-conventions.git diff --git a/coding-conventions b/coding-conventions new file mode 160000 index 00000000..12ed12c7 --- /dev/null +++ b/coding-conventions @@ -0,0 +1 @@ +Subproject commit 12ed12c78a8b1d55eb0ae06d176c9a01103b81a5 diff --git a/conventions/IMPORTS.md b/conventions/IMPORTS.md deleted file mode 100644 index 866add61..00000000 --- a/conventions/IMPORTS.md +++ /dev/null @@ -1,91 +0,0 @@ -# Import conventions - -Applies to all packages in this monorepo (`hightide-web`, `hightide-native`, `hightide-design`, `hightide-utils`). - -## Relative package imports - -Imports from **within the same package** must use relative paths (`./` or `../`), never use path alias like `@/`. - -```ts -// Prefer -import { useTheme } from '../../global-contexts/theme/ThemeContext' -import type { ChatTheme } from '../../theme/types/components/chat' - -// Avoid -import { useTheme } from '@/src/global-contexts/theme/ThemeContext' -import type { ChatTheme } from '@/src/theme/types/components/chat' -``` - -Cross-package imports (for example `@helpwave/hightide-design/primitive-tokens` or `@helpwave/hightide-utils/hooks`) keep their published package names. Do not rewrite those to relative paths across package boundaries. - -Design tokens: import primitives from `@helpwave/hightide-design/primitive-tokens`, theme adapters / `StateBasedProperty` from `/theme-tokens`, semantic mappers from `/semantic-tokens`, component tokens from `/component-tokens`, `designSystem` / construction helpers from `/design-system`, and `HexColorUtils` from `/utils`. - -## No index / barrel imports - -Local package imports must target the **concrete module file**, never a folder `index` barrel. - -```ts -// Prefer -import { useTheme } from '../../global-contexts/theme/ThemeContext' -import type { StyleOverwrite } from '../../theme/types/resolver' -import { Button } from '../user-interaction/Button' - -// Avoid -import { useTheme } from '../../global-contexts/theme' -import type { StyleOverwrite } from '../../theme' -import { Button } from '../user-interaction' -``` - -Barrel `index.ts` files may still exist for package **public** entrypoints. Do not import through them from other files inside this package. - -## Bundle imports from the same module - -Imports from the **same module** must be combined into a single import statement. Do not split value and type imports across multiple statements for one path. - -When an import has more than one binding, write each binding on its own line: - -```ts -import { - ScrollView, - View, - type StyleProp, - type ViewProps, - type ViewStyle -} from 'react-native' -``` - -Single-binding imports may stay on one line: - -```ts -import { useMemo } from 'react' -``` - -## Import order by externality - -Group and order imports from most external to most local: - -1. **External dependencies** — `react`, `react-native`, third-party libraries, Node built-ins -2. **Workspace / sibling packages** — `@helpwave/…` (and other monorepo packages) -3. **Internal package imports** — relative `./` / `../` paths - -Separate groups with a blank line. Within a group, keep a stable alphabetical order when practical. - -```ts -import { - useMemo, - type ReactNode -} from 'react' -import { - Text, - View, - type StyleProp, - type ViewStyle -} from 'react-native' -import { Check } from 'lucide-react-native' - -import { hightideTypography } from '@helpwave/hightide-design/primitive-tokens' -import { HexColorUtils } from '@helpwave/hightide-design/utils' - -import { useTheme } from '../../global-contexts/theme/ThemeContext' -import type { ChatTheme } from '../../theme/types/components/chat' -``` diff --git a/conventions/Typescript-Enum.md b/conventions/Typescript-Enum.md deleted file mode 100644 index cfaee37b..00000000 --- a/conventions/Typescript-Enum.md +++ /dev/null @@ -1,34 +0,0 @@ -### TypeScript Enum Pattern - -When defining a finite set of string values in TypeScript, use the following pattern instead of a TypeScript enum. - -Pattern (replace A with the name of the enum) - -```typescript -// 1. Array definition -const aValues = ["value1", "value2"] as const; -// 2. Type definition -export type A = (typeof values)[number]; -// 3. Set definition -const allowedAValues: ReadonlySet = new Set(values); -// 4. Type-Check definition -function isAValue(value: unknown): value is A { - if (typeof value !== "string") return false; - return allowedAValues.has(value); -} - -// 5. Utils definition -export const AUtils = { - array: aValues, - set: allowedAValues, - typeCheck: isAValue, -}; -``` - -1. enum Name in camelCase + "Values" suffix -2. enum Name -3. "allowed" prefix + enum Name + + "Values" suffix -4. "is" prefix + enum Name + "Value" suffix -5. enum Name + "Utils" suffix - -For the actual implementation remove the comments diff --git a/conventions/PUBLISHING.md b/documentation/PUBLISHING.md similarity index 100% rename from conventions/PUBLISHING.md rename to documentation/PUBLISHING.md diff --git a/packages/hightide-native/src/components/user-interaction/Checkbox.tsx b/packages/hightide-native/src/components/user-interaction/Checkbox.tsx index 6581d815..a90c3b1b 100644 --- a/packages/hightide-native/src/components/user-interaction/Checkbox.tsx +++ b/packages/hightide-native/src/components/user-interaction/Checkbox.tsx @@ -8,7 +8,7 @@ import { } from 'react-native' import { useControlledState, - useEventCallbackStabilizer + useStableEvent } from '@helpwave/hightide-utils/hooks' import type { ColorPairToken } from '@helpwave/hightide-design/theme-tokens' @@ -74,8 +74,8 @@ export const Checkbox = ({ const interactive = !disabled && !readOnly const [isPressed, setIsPressed] = useState(false) - const onEditCompleteStable = useEventCallbackStabilizer(onEditComplete) - const onValueChangeStable = useEventCallbackStabilizer(onValueChange) + const onEditCompleteStable = useStableEvent(onEditComplete) + const onValueChangeStable = useStableEvent(onValueChange) const onChangeWrapper = useCallback((nextValue: boolean) => { onValueChangeStable(nextValue) diff --git a/packages/hightide-native/src/components/user-interaction/FileInput/FileInputRoot.tsx b/packages/hightide-native/src/components/user-interaction/FileInput/FileInputRoot.tsx index e1a0f52c..a58ef7ff 100644 --- a/packages/hightide-native/src/components/user-interaction/FileInput/FileInputRoot.tsx +++ b/packages/hightide-native/src/components/user-interaction/FileInput/FileInputRoot.tsx @@ -4,7 +4,7 @@ import { useCallback, useMemo } from 'react' import type { ColorPairToken } from '@helpwave/hightide-design/theme-tokens' import { useControlledState, - useEventCallbackStabilizer + useStableEvent } from '@helpwave/hightide-utils/hooks' import type { @@ -65,10 +65,10 @@ export function FileInputRoot({ defaultValue: resolveFileInputMaxFiles(maxFiles) === 1 ? false : initialIsOpen, }) - const onEditCompleteStable = useEventCallbackStabilizer(onEditComplete) - const onCloseStable = useEventCallbackStabilizer(onClose) - const onIsOpenChangeStable = useEventCallbackStabilizer(onIsOpenChange) - const pickFilesStable = useEventCallbackStabilizer(pickFiles) + const onEditCompleteStable = useStableEvent(onEditComplete) + const onCloseStable = useStableEvent(onClose) + const onIsOpenChangeStable = useStableEvent(onIsOpenChange) + const pickFilesStable = useStableEvent(pickFiles) const commitFiles = useCallback((next: readonly FileInputItem[]) => { setFiles(next) diff --git a/packages/hightide-native/src/components/user-interaction/MultiSelect/MultiSelectRoot.tsx b/packages/hightide-native/src/components/user-interaction/MultiSelect/MultiSelectRoot.tsx index c11bfae2..1cce2ffa 100644 --- a/packages/hightide-native/src/components/user-interaction/MultiSelect/MultiSelectRoot.tsx +++ b/packages/hightide-native/src/components/user-interaction/MultiSelect/MultiSelectRoot.tsx @@ -2,7 +2,7 @@ import type { ReactNode } from 'react' import { useCallback, useMemo, useState } from 'react' import type { ColorPairToken } from '@helpwave/hightide-design/theme-tokens' -import { useEventCallbackStabilizer } from '@helpwave/hightide-utils/hooks' +import { useStableEvent } from '@helpwave/hightide-utils/hooks' import { useMultiSelect } from '../../../hooks/useMultiSelect' import type { @@ -96,8 +96,8 @@ export function MultiSelectRoot({ .filter((id): id is string => id !== undefined) }, [compare, idToOptionMap, initialValue]) - const onValueChangeStable = useEventCallbackStabilizer(onValueChange) - const onEditCompleteStable = useEventCallbackStabilizer(onEditComplete) + const onValueChangeStable = useStableEvent(onValueChange) + const onEditCompleteStable = useStableEvent(onEditComplete) const onValueChangeWrapper = useCallback((ids: string[]) => { const values = ids diff --git a/packages/hightide-native/src/components/user-interaction/Select/SelectRoot.tsx b/packages/hightide-native/src/components/user-interaction/Select/SelectRoot.tsx index b8b81d7d..bdac2bcb 100644 --- a/packages/hightide-native/src/components/user-interaction/Select/SelectRoot.tsx +++ b/packages/hightide-native/src/components/user-interaction/Select/SelectRoot.tsx @@ -2,7 +2,7 @@ import type { ReactNode } from 'react' import { useCallback, useMemo, useState } from 'react' import type { ColorPairToken } from '@helpwave/hightide-design/theme-tokens' -import { useEventCallbackStabilizer } from '@helpwave/hightide-utils/hooks' +import { useStableEvent } from '@helpwave/hightide-utils/hooks' import { useSelect } from '../../../hooks/useSelect' import type { @@ -92,9 +92,9 @@ export function SelectRoot({ return Object.values(idToOptionMap).find((option) => compare(option.value.value, initialValue))?.value.id ?? null }, [compare, idToOptionMap, initialValue]) - const onValueChangeStable = useEventCallbackStabilizer(onValueChange) - const onEditCompleteStable = useEventCallbackStabilizer(onEditComplete) - const onIsOpenChangeStable = useEventCallbackStabilizer(onIsOpenChange) + const onValueChangeStable = useStableEvent(onValueChange) + const onEditCompleteStable = useStableEvent(onEditComplete) + const onIsOpenChangeStable = useStableEvent(onIsOpenChange) const onValueChangeWrapper = useCallback((id: string) => { const option = idToOptionMap[id] diff --git a/packages/hightide-native/src/components/user-interaction/Switch.tsx b/packages/hightide-native/src/components/user-interaction/Switch.tsx index 86fcd9dd..4cc4a083 100644 --- a/packages/hightide-native/src/components/user-interaction/Switch.tsx +++ b/packages/hightide-native/src/components/user-interaction/Switch.tsx @@ -14,7 +14,7 @@ import { import { useControlledState, - useEventCallbackStabilizer + useStableEvent } from '@helpwave/hightide-utils/hooks' import { useDebugContext } from '../../global-contexts/debug' @@ -80,8 +80,8 @@ export const Switch = ({ const interactive = !disabled && !readOnly const [isPressed, setIsPressed] = useState(false) - const onEditCompleteStable = useEventCallbackStabilizer(onEditComplete) - const onValueChangeStable = useEventCallbackStabilizer(onValueChange) + const onEditCompleteStable = useStableEvent(onEditComplete) + const onValueChangeStable = useStableEvent(onValueChange) const onChangeWrapper = useCallback((nextValue: boolean) => { onValueChangeStable(nextValue) diff --git a/packages/hightide-native/src/hooks/useMultiSelect.ts b/packages/hightide-native/src/hooks/useMultiSelect.ts index ea153f92..685893b2 100644 --- a/packages/hightide-native/src/hooks/useMultiSelect.ts +++ b/packages/hightide-native/src/hooks/useMultiSelect.ts @@ -6,7 +6,7 @@ import { } from 'react' import { - useEventCallbackStabilizer, + useStableEvent, useListNavigation, useMultiSelection, useSearch, @@ -89,8 +89,8 @@ export function useMultiSelect({ isControlled: controlledValue !== undefined, }) - const editCompleteStable = useEventCallbackStabilizer(onEditComplete) - const onCloseStable = useEventCallbackStabilizer(onClose) + const editCompleteStable = useStableEvent(onEditComplete) + const onCloseStable = useStableEvent(onClose) const { searchResult: visibleOptions } = useSearch({ items: options, diff --git a/packages/hightide-native/src/hooks/useSelect.ts b/packages/hightide-native/src/hooks/useSelect.ts index add7549e..eca12568 100644 --- a/packages/hightide-native/src/hooks/useSelect.ts +++ b/packages/hightide-native/src/hooks/useSelect.ts @@ -6,7 +6,7 @@ import { } from 'react' import { - useEventCallbackStabilizer, + useStableEvent, useListNavigation, useSearch, useSingleSelection, @@ -76,10 +76,10 @@ export function useSelect({ const [isOpen, setIsOpen] = useState(initialIsOpen) const [searchQuery, setSearchQuery] = useState('') - const onValueChangeStable = useEventCallbackStabilizer(onValueChange) - const onEditCompleteStable = useEventCallbackStabilizer(onEditComplete) - const onCloseStable = useEventCallbackStabilizer(onClose) - const onIsOpenChangeStable = useEventCallbackStabilizer(onIsOpenChange) + const onValueChangeStable = useStableEvent(onValueChange) + const onEditCompleteStable = useStableEvent(onEditComplete) + const onCloseStable = useStableEvent(onClose) + const onIsOpenChangeStable = useStableEvent(onIsOpenChange) const onSelectionChangeWrapper = useCallback((id: string | null) => { if (id === null) return diff --git a/packages/hightide-utils/CHANGELOG.md b/packages/hightide-utils/CHANGELOG.md index e53f3581..f5688091 100644 --- a/packages/hightide-utils/CHANGELOG.md +++ b/packages/hightide-utils/CHANGELOG.md @@ -5,6 +5,21 @@ 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.2.0] - unpublished + +### Added + +- `createFormStore` takes `initialValues`, `initialTouchedValues`, and a fixed `validation` list of `FormValidator` objects. Field errors are `string[]`. Async checks use `validationRunnerError` (`ValidationRunnerState`). `FormValidationUtils` groups checks by `string`, `number`, and `selection`, each with `mapTranslation`. `FormValidationErrorTypeUtils` lives in `utils/form-validation`. Validation messages use `validationError*` translation keys +- `useStateMachine` and `useStateMachineBinding` hooks + +### Changed + +- Renamed `useEventCallbackStabilizer` to `useStableEvent` + +### Removed + +- `EmailValidationUtils` and the standalone `validateEmail` export. Email checks go through `FormValidationUtils.email` + ## [0.1.8] - 2026-09-21 ### Added diff --git a/packages/hightide-utils/locales/de-DE.arb b/packages/hightide-utils/locales/de-DE.arb index c021a731..7942e10c 100644 --- a/packages/hightide-utils/locales/de-DE.arb +++ b/packages/hightide-utils/locales/de-DE.arb @@ -70,6 +70,53 @@ }, "menu": "Menü", "minLengthError": "Mindestlänge nicht erreicht.", + "validationErrorNotEmpty": "Das Feld darf nicht leer sein.", + "validationErrorInvalidEmail": "Die E-Mail ist ungültig.", + "validationErrorTooShort": "Der Wert muss mindestens {min} Zeichen enthalten.", + "@validationErrorTooShort": { + "placeholders": { + "min": { "type": "number" } + } + }, + "validationErrorTooLong": "Der Wert darf höchstens {max} Zeichen enthalten.", + "@validationErrorTooLong": { + "placeholders": { + "max": { "type": "number" } + } + }, + "validationErrorOutOfRangeString": "Der Wert muss zwischen {min} und {max} Zeichen lang sein.", + "@validationErrorOutOfRangeString": { + "placeholders": { + "min": { "type": "number" }, + "max": { "type": "number" } + } + }, + "validationErrorOutOfRangeNumber": "Der Wert muss zwischen {min} und {max} liegen.", + "@validationErrorOutOfRangeNumber": { + "placeholders": { + "min": { "type": "number" }, + "max": { "type": "number" } + } + }, + "validationErrorOutOfRangeSelectionItems": "Es müssen zwischen {min} und {max} Elemente ausgewählt werden.", + "@validationErrorOutOfRangeSelectionItems": { + "placeholders": { + "min": { "type": "number" }, + "max": { "type": "number" } + } + }, + "validationErrorTooFewSelectionItems": "Es müssen mindestens {min} Elemente ausgewählt werden.", + "@validationErrorTooFewSelectionItems": { + "placeholders": { + "min": { "type": "number" } + } + }, + "validationErrorTooManySelectionItems": "Es müssen maximal {max} Elemente ausgewählt werden.", + "@validationErrorTooManySelectionItems": { + "placeholders": { + "max": { "type": "number" } + } + }, "more": "Mehr", "next": "Weiter", "no": "Nein", @@ -245,8 +292,6 @@ "playTrace": "Trace abspielen", "startDate": "Start", "startsWith": "Beginnt mit", - "notEmpty": "Das Feld darf nicht leer sein.", - "invalidEmail": "Die E-Mail ist ungültig.", "isFalse": "Ist falsch", "isTrue": "Ist wahr", "isUndefined": "Ist undefiniert", @@ -257,51 +302,6 @@ "before": "Vor", "onOrAfter": "Nach oder Gleich", "onOrBefore": "Vor oder Gleich", - "tooShort": "Der Wert muss mindestens {min} Zeichen enthalten.", - "@tooShort": { - "placeholders": { - "min": { "type": "number" } - } - }, - "tooLong": "Der Wert darf höchstens {max} Zeichen enthalten.", - "@tooLong": { - "placeholders": { - "max": { "type": "number" } - } - }, - "outOfRangeString": "Der Wert muss zwischen {min} und {max} Zeichen lang sein.", - "@outOfRangeString": { - "placeholders": { - "min": { "type": "number" }, - "max": { "type": "number" } - } - }, - "outOfRangeNumber": "Der Wert muss zwischen {min} und {max} liegen.", - "@outOfRangeNumber": { - "placeholders": { - "min": { "type": "number" }, - "max": { "type": "number" } - } - }, - "outOfRangeSelectionItems": "Es müssen zwischen {min} und {max} Elemente ausgewählt werden.", - "@outOfRangeSelectionItems": { - "placeholders": { - "min": { "type": "number" }, - "max": { "type": "number" } - } - }, - "tooFewSelectionItems": "Es müssen mindestens {min} Elemente ausgewählt werden.", - "@tooFewSelectionItems": { - "placeholders": { - "min": { "type": "number" } - } - }, - "tooManySelectionItems": "Es müssen maximal {max} Elemente ausgewählt werden.", - "@tooManySelectionItems": { - "placeholders": { - "max": { "type": "number" } - } - }, "age": "Alter", "entryDate": "Eintragsdatum", "identifier": "Identifikator", diff --git a/packages/hightide-utils/locales/en-US.arb b/packages/hightide-utils/locales/en-US.arb index af5ae481..d13cd00b 100644 --- a/packages/hightide-utils/locales/en-US.arb +++ b/packages/hightide-utils/locales/en-US.arb @@ -71,6 +71,53 @@ }, "menu": "Menu", "minLengthError": "Minimum length not met.", + "validationErrorNotEmpty": "The field cannot be empty.", + "validationErrorInvalidEmail": "The email is not valid.", + "validationErrorTooShort": "The value requires at least {min} characters.", + "@validationErrorTooShort": { + "placeholders": { + "min": { "type": "number" } + } + }, + "validationErrorTooLong": "The value requires less than {max} characters.", + "@validationErrorTooLong": { + "placeholders": { + "max": { "type": "number" } + } + }, + "validationErrorOutOfRangeString": "The value needs to have between {min} and {max} characters.", + "@validationErrorOutOfRangeString": { + "placeholders": { + "min": { "type": "number" }, + "max": { "type": "number" } + } + }, + "validationErrorOutOfRangeNumber": "The value must be between {min} and {max}.", + "@validationErrorOutOfRangeNumber": { + "placeholders": { + "min": { "type": "number" }, + "max": { "type": "number" } + } + }, + "validationErrorOutOfRangeSelectionItems": "Between {min} and {max} items must be selected.", + "@validationErrorOutOfRangeSelectionItems": { + "placeholders": { + "min": { "type": "number" }, + "max": { "type": "number" } + } + }, + "validationErrorTooFewSelectionItems": "Select at least {min} items.", + "@validationErrorTooFewSelectionItems": { + "placeholders": { + "min": { "type": "number" } + } + }, + "validationErrorTooManySelectionItems": "Select at most {max} items.", + "@validationErrorTooManySelectionItems": { + "placeholders": { + "max": { "type": "number" } + } + }, "more": "More", "next": "Next", "no": "No", @@ -246,8 +293,6 @@ "playTrace": "Play trace", "startDate": "Start", "startsWith": "Starts with", - "notEmpty": "The field cannot be empty.", - "invalidEmail": "The email is not valid.", "isFalse": "Is false", "isTrue": "Is true", "isUndefined": "Is undefined", @@ -258,51 +303,6 @@ "before": "Before", "onOrAfter": "After or Equal", "onOrBefore": "Before or Equal", - "tooShort": "The value requires at least {min} characters.", - "@tooShort": { - "placeholders": { - "min": { "type": "number" } - } - }, - "tooLong": "The value requires less than {max} characters.", - "@tooLong": { - "placeholders": { - "max": { "type": "number" } - } - }, - "outOfRangeString": "The value needs to have between {min} and {max} characters.", - "@outOfRangeString": { - "placeholders": { - "min": { "type": "number" }, - "max": { "type": "number" } - } - }, - "outOfRangeNumber": "The value must be between {min} and {max}.", - "@outOfRangeNumber": { - "placeholders": { - "min": { "type": "number" }, - "max": { "type": "number" } - } - }, - "outOfRangeSelectionItems": "Between {min} and {max} items must be selected.", - "@outOfRangeSelectionItems": { - "placeholders": { - "min": { "type": "number" }, - "max": { "type": "number" } - } - }, - "tooFewSelectionItems": "Select at least {min} items.", - "@tooFewSelectionItems": { - "placeholders": { - "min": { "type": "number" } - } - }, - "tooManySelectionItems": "Select at most {max} items.", - "@tooManySelectionItems": { - "placeholders": { - "max": { "type": "number" } - } - }, "age": "Age", "entryDate": "Entry Date", "identifier": "Identifier", diff --git a/packages/hightide-utils/package.json b/packages/hightide-utils/package.json index 29bb6185..51667b45 100644 --- a/packages/hightide-utils/package.json +++ b/packages/hightide-utils/package.json @@ -42,6 +42,11 @@ "require": "./dist/context/debug/index.cjs", "import": "./dist/context/debug/index.js" }, + "./form": { + "types": "./dist/form/index.d.ts", + "require": "./dist/form/index.cjs", + "import": "./dist/form/index.js" + }, "./hooks": { "types": "./dist/hooks/index.d.ts", "require": "./dist/hooks/index.cjs", @@ -52,6 +57,11 @@ "require": "./dist/i18n/index.cjs", "import": "./dist/i18n/index.js" }, + "./interfaces": { + "types": "./dist/interfaces/index.d.ts", + "require": "./dist/interfaces/index.cjs", + "import": "./dist/interfaces/index.js" + }, "./utils": { "types": "./dist/utils/index.d.ts", "require": "./dist/utils/index.cjs", @@ -71,7 +81,8 @@ "test": "jest" }, "dependencies": { - "@helpwave/internationalization": "0.4.0" + "@helpwave/internationalization": "0.4.0", + "zustand": "5.0.15" }, "peerDependencies": { "react": "^19.0.0" @@ -100,4 +111,4 @@ "bugs": { "url": "https://github.com/helpwave/hightide/issues" } -} +} \ No newline at end of file diff --git a/packages/hightide-utils/src/context/localization/useCreateLocalizationContext.ts b/packages/hightide-utils/src/context/localization/useCreateLocalizationContext.ts index 38d5e0fa..1ec01b5c 100644 --- a/packages/hightide-utils/src/context/localization/useCreateLocalizationContext.ts +++ b/packages/hightide-utils/src/context/localization/useCreateLocalizationContext.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo } from 'react' -import { useEventCallbackStabilizer } from '../../hooks/useEventCallbackStabelizer' +import { useStableEvent } from '../../hooks/useStableEvent' import { useSimpleStoreSyncedValue, type SimpleValueStore } from '../../hooks/useSimpleStoreSyncedValue' import type { LocalizationContextValue, SupportedLocalesConfig } from './LocalizationContext' import { StringUnionUtils } from '../../utils/stringUnion' @@ -194,31 +194,31 @@ export const useCreateLocalizationContext = ({ setStoredCalendarType(calendarType) }, [calendarType, setStoredCalendarType]) - const onChangeRef = useEventCallbackStabilizer(onChangedLocale) + const onChangeRef = useStableEvent(onChangedLocale) useEffect(() => { onChangeRef?.(resolvedLocale) }, [resolvedLocale, onChangeRef]) - const onChangeTimeZoneRef = useEventCallbackStabilizer(onChangedTimeZone) + const onChangeTimeZoneRef = useStableEvent(onChangedTimeZone) useEffect(() => { onChangeTimeZoneRef?.(resolvedTimeZone) }, [resolvedTimeZone, onChangeTimeZoneRef]) - const onChangeIs24HourFormatRef = useEventCallbackStabilizer(onChangedIs24HourFormat) + const onChangeIs24HourFormatRef = useStableEvent(onChangedIs24HourFormat) useEffect(() => { onChangeIs24HourFormatRef?.(resolvedIs24HourFormat) }, [resolvedIs24HourFormat, onChangeIs24HourFormatRef]) - const onChangeStartingWeekdayRef = useEventCallbackStabilizer(onChangedStartingWeekday) + const onChangeStartingWeekdayRef = useStableEvent(onChangedStartingWeekday) useEffect(() => { onChangeStartingWeekdayRef?.(resolvedStartingWeekday) }, [resolvedStartingWeekday, onChangeStartingWeekdayRef]) - const onChangeCalendarTypeRef = useEventCallbackStabilizer(onChangedCalendarType) + const onChangeCalendarTypeRef = useStableEvent(onChangedCalendarType) useEffect(() => { onChangeCalendarTypeRef?.(resolvedCalendarType) diff --git a/packages/hightide-utils/src/context/theme/useCreateThemeConfig.ts b/packages/hightide-utils/src/context/theme/useCreateThemeConfig.ts index 5da8c2a7..4b58e7e2 100644 --- a/packages/hightide-utils/src/context/theme/useCreateThemeConfig.ts +++ b/packages/hightide-utils/src/context/theme/useCreateThemeConfig.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo } from 'react' -import { useEventCallbackStabilizer } from '../../hooks/useEventCallbackStabelizer' +import { useStableEvent } from '../../hooks/useStableEvent' import type { SimpleValueStore } from '../../hooks/useSimpleStoreSyncedValue' import { useSimpleStoreSyncedValue } from '../../hooks/useSimpleStoreSyncedValue' import type { Locale } from '../localization/LocalizationContext' @@ -91,7 +91,7 @@ export const useCreateThemeConfig = ({ } }, [deleteStoredTheme, setStoredTheme, supportedThemes, theme]) - const onChangeRef = useEventCallbackStabilizer(onChangedTheme) + const onChangeRef = useStableEvent(onChangedTheme) useEffect(() => { onChangeRef?.(resolvedThemeMode) diff --git a/packages/hightide-utils/src/form/FormFieldWrapper.tsx b/packages/hightide-utils/src/form/FormFieldWrapper.tsx new file mode 100644 index 00000000..e12ee9ec --- /dev/null +++ b/packages/hightide-utils/src/form/FormFieldWrapper.tsx @@ -0,0 +1,81 @@ +import type { InputStateEvent } from '../interfaces/input' +import type { FormValues, ValidationRunnerState } from './FormStore' +import { useFormStore } from './FormStoreContext' +import { useFieldTouchedTrigger, type TouchedTrigger } from './useFieldTouchedTrigger' + +const emptyFieldErrors: string[] = [] + +export type FormFieldWrapperConfig< + T extends FormValues, + E extends InputStateEvent = InputStateEvent +> = { + isRequired?: boolean | ((values: T) => boolean), + isDisabled?: boolean | ((values: T) => boolean), + touchedTrigger?: TouchedTrigger, +} + +export type FormFieldWrapperBag = { + name: K, + + value: T[K], + errors: string[], + validationRunnerError: ValidationRunnerState | undefined, + + isTouched: boolean, + isRequired: boolean, + isDisabled: boolean, + + setValue: (value: T[K]) => void, + setTouched: (touched?: boolean) => void, + onStateEvent: (event: E) => void, +}; + +type FormFieldWrapperProps = { + name: K, + config?: FormFieldWrapperConfig, + children: (field: FormFieldWrapperBag) => React.ReactNode, +}; + +export function FormFieldWrapper< + T extends FormValues, + K extends keyof T, + E extends InputStateEvent = InputStateEvent +>({ + name, + config, + children, +}: FormFieldWrapperProps) { + const value = useFormStore()(state => state.values[name]) + const errors = useFormStore()(state => state.errors[name]) ?? emptyFieldErrors + const validationRunnerError = useFormStore()(state => state.validationRunnerError[name]) + const isTouched = useFormStore()(state => state.touchedValues[name] ?? false) + + const setValue = useFormStore()(state => state.setValue) + const setTouchedValue = useFormStore()(state => state.setTouchedValue) + const onStateEvent = useFieldTouchedTrigger(config?.touchedTrigger, value, () => { + setTouchedValue(name, true) + }) + + const isRequired = useFormStore()(state => + typeof config?.isRequired === 'function' + ? config.isRequired(state.values) + : config?.isRequired ?? false) + + const isDisabled = useFormStore()(state => + typeof config?.isDisabled === 'function' + ? config.isDisabled(state.values) + : config?.isDisabled ?? false) + + return children({ + name, + value, + errors, + validationRunnerError, + isTouched, + isRequired, + isDisabled, + setValue: value => setValue(name, value), + setTouched: touched => setTouchedValue(name, touched ?? true), + onStateEvent, + }) +} \ No newline at end of file diff --git a/packages/hightide-utils/src/form/FormStore.ts b/packages/hightide-utils/src/form/FormStore.ts new file mode 100644 index 00000000..d97a108d --- /dev/null +++ b/packages/hightide-utils/src/form/FormStore.ts @@ -0,0 +1,237 @@ +import { create, type StoreApi, type UseBoundStore } from 'zustand' + +export type FormValues = Record + +export type FormValidator = { + dependsOn: (keyof T)[] | 'all', + validatorFn: (values: T) => Partial>, +} + +export type CreateFormStoreOptions = { + initialValues: T, + initialTouchedValues?: Partial>, + validation?: FormValidator[], +} + +export type ValidationRunnerState = { + id: number, + isRunning: boolean, + errors: string[], +} + +export type FormFieldErrors = Partial> + +export type FormValidationRunnerError = Partial> + +export type FormState = { + values: T, + errors: FormFieldErrors, + validationRunnerError: FormValidationRunnerError, + touchedValues: Partial>, + + reset: () => void, + validate: () => number, + + setValue: ( + field: K, + value: T[K] + ) => void, + setValues: (values: Partial) => void, + + setValidationRunnerError: ( + field: K, + runner: ValidationRunnerState | undefined + ) => void, + + setTouchedValue: ( + field: K, + touched?: boolean + ) => void, + setTouchedValues: (touchedValues: Partial>) => void, +} + +export type FormStore = + UseBoundStore>> + +export const isValidationRunnerInvalid = (runner: ValidationRunnerState | undefined): boolean => { + if (!runner) return false + return runner.isRunning || runner.errors.length > 0 +} + +export const countFormErrors = ( + errors: FormFieldErrors, + validationRunnerError: FormValidationRunnerError +): number => { + const fields = new Set([ + ...(Object.keys(errors) as (keyof T)[]), + ...(Object.keys(validationRunnerError) as (keyof T)[]), + ]) + + let count = 0 + for (const field of fields) { + count += errors[field]?.length ?? 0 + const runner = validationRunnerError[field] + if (!runner) continue + if (runner.isRunning && runner.errors.length === 0) { + count += 1 + continue + } + count += runner.errors.length + } + + return count +} + +const appendFieldError = ( + errors: FormFieldErrors, + field: keyof T, + error: string | undefined +) => { + if (error === undefined) return + errors[field] = [...(errors[field] ?? []), error] +} + +const validatorDependsOnChange = ( + dependsOn: FormValidator['dependsOn'], + changedFields: (keyof T)[] +) => { + if (dependsOn === 'all') return true + return dependsOn.some(field => changedFields.includes(field)) +} + +const collectErrors = ( + validation: FormValidator[], + values: T +): FormFieldErrors => { + const errors: FormFieldErrors = {} + + for (const validator of validation) { + const result = validator.validatorFn(values) + for (const field of Object.keys(result) as (keyof T)[]) { + appendFieldError(errors, field, result[field]) + } + } + + return errors +} + +const revalidateChangedFields = ( + validation: FormValidator[], + errors: FormFieldErrors, + values: T, + changedFields: (keyof T)[] +): FormFieldErrors => { + const affected = validation.filter(validator => validatorDependsOnChange(validator.dependsOn, changedFields)) + const resetFields = new Set(changedFields) + const results = new Map, Partial>>() + + for (const validator of affected) { + const result = validator.validatorFn(values) + results.set(validator, result) + for (const field of Object.keys(result) as (keyof T)[]) { + resetFields.add(field) + } + } + + const nextErrors: FormFieldErrors = { ...errors } + for (const field of resetFields) { + nextErrors[field] = [] + } + + for (const validator of validation) { + const result = results.get(validator) ?? validator.validatorFn(values) + for (const field of Object.keys(result) as (keyof T)[]) { + if (!resetFields.has(field)) continue + appendFieldError(nextErrors, field, result[field]) + } + } + + for (const field of resetFields) { + if (!nextErrors[field]?.length) { + delete nextErrors[field] + } + } + + return nextErrors +} + +export function createFormStore({ + initialValues, + initialTouchedValues = {}, + validation = [], +}: CreateFormStoreOptions): FormStore { + return create>((set, get) => ({ + values: { ...initialValues }, + errors: collectErrors(validation, initialValues), + validationRunnerError: {}, + touchedValues: { ...initialTouchedValues }, + + setValues: (values) => { + set(state => { + const nextValues = { ...state.values, ...values } + return { + values: nextValues, + errors: revalidateChangedFields(validation, state.errors, nextValues, Object.keys(values) as (keyof T)[]), + } + }) + }, + + reset: () => { + set({ + values: { ...initialValues }, + errors: collectErrors(validation, initialValues), + validationRunnerError: {}, + touchedValues: { ...initialTouchedValues }, + }) + }, + + validate: () => { + const errors = collectErrors(validation, get().values) + set({ errors }) + return countFormErrors(errors, get().validationRunnerError) + }, + + setValue: (field, value) => { + set(state => { + const nextValues = { + ...state.values, + [field]: value, + } + return { + values: nextValues, + errors: revalidateChangedFields(validation, state.errors, nextValues, [field]), + } + }) + }, + + setValidationRunnerError: (field, runner) => { + set(state => { + const validationRunnerError = { ...state.validationRunnerError } + if (runner) { + validationRunnerError[field] = runner + } else { + delete validationRunnerError[field] + } + return { validationRunnerError } + }) + }, + + setTouchedValue: (field, touched = true) => { + set(state => ({ + touchedValues: { + ...state.touchedValues, + [field]: touched, + }, + })) + }, + + setTouchedValues: (touchedValues) => { + set(state => ({ + touchedValues: { + ...state.touchedValues, + ...touchedValues, + }, + })) + }, + })) +} diff --git a/packages/hightide-utils/src/form/FormStoreContext.tsx b/packages/hightide-utils/src/form/FormStoreContext.tsx new file mode 100644 index 00000000..8e949d3d --- /dev/null +++ b/packages/hightide-utils/src/form/FormStoreContext.tsx @@ -0,0 +1,32 @@ +import { createContext, useContext } from 'react' +import type { FormStore, FormValues } from './FormStore' + +export const FormStoreContext = createContext | null>(null) + +export function FormStoreProvider({ + store, + children, +}: { + store: FormStore, + children: React.ReactNode, +}) { + return ( + }> + {children} + + ) +} + +export function useFormStore< + T extends FormValues +>(): FormStore { + const store = useContext(FormStoreContext) + + if (!store) { + throw new Error( + 'FormField must be used inside a Form' + ) + } + + return store as FormStore +} \ No newline at end of file diff --git a/packages/hightide-utils/src/form/index.ts b/packages/hightide-utils/src/form/index.ts new file mode 100644 index 00000000..1072a342 --- /dev/null +++ b/packages/hightide-utils/src/form/index.ts @@ -0,0 +1,4 @@ +export * from './FormFieldWrapper' +export * from './useFieldTouchedTrigger' +export * from './FormStore' +export * from './FormStoreContext' diff --git a/packages/hightide-utils/src/form/useFieldTouchedTrigger.ts b/packages/hightide-utils/src/form/useFieldTouchedTrigger.ts new file mode 100644 index 00000000..a03df949 --- /dev/null +++ b/packages/hightide-utils/src/form/useFieldTouchedTrigger.ts @@ -0,0 +1,48 @@ +import { useCallback, useEffect, useRef } from 'react' +import type { InputStateEvent } from '../interfaces/input' +import { useDebouncer } from '../hooks/useDebouncer' +import { useStableEvent } from '../hooks/useStableEvent' +import { useThrottle } from '../hooks/useThrottle' + +export type TouchedTrigger = { + event?: ((event: E) => boolean) | null, + debounce?: number | null, + throttle?: number | null, +} + +const defaultTouchedTriggerDebounce = 1500 + +export function useFieldTouchedTrigger( + touchedTrigger: TouchedTrigger | undefined, + value: unknown, + markTouched: () => void +): (event: E) => void { + const matchesEvent = touchedTrigger?.event === undefined ? null : touchedTrigger.event + const debounce = touchedTrigger?.debounce === undefined ? defaultTouchedTriggerDebounce : touchedTrigger.debounce + const throttle = touchedTrigger?.throttle === undefined ? null : touchedTrigger.throttle + + const withDebounce = useDebouncer(typeof debounce === 'number' ? debounce : 0) + const withThrottle = useThrottle(typeof throttle === 'number' ? throttle : 0) + const markTouchedStable = useStableEvent(markTouched) + const matchesEventStable = useStableEvent<(event: E) => boolean>(matchesEvent ?? (() => false)) + const previousValueRef = useRef(value) + + useEffect(() => { + if (Object.is(previousValueRef.current, value)) { + return + } + previousValueRef.current = value + if (typeof debounce === 'number') { + withDebounce(markTouchedStable, debounce) + } + if (typeof throttle === 'number') { + withThrottle(markTouchedStable, throttle) + } + }, [debounce, markTouchedStable, throttle, value, withDebounce, withThrottle]) + + return useCallback((event: E) => { + if (matchesEvent !== null && matchesEventStable(event)) { + markTouchedStable() + } + }, [markTouchedStable, matchesEvent, matchesEventStable]) +} diff --git a/packages/hightide-utils/src/hooks/index.ts b/packages/hightide-utils/src/hooks/index.ts index 271a1c2d..f76bde8f 100644 --- a/packages/hightide-utils/src/hooks/index.ts +++ b/packages/hightide-utils/src/hooks/index.ts @@ -4,7 +4,7 @@ export * from './useChangingNumber' export * from './useControlledState' export * from './useDebouncer' export * from './useDelay' -export * from './useEventCallbackStabelizer' +export * from './useEditCompletable' export * from './useListNavigation' export * from './useLogOnce' export * from './useLogUnstableDependencies' @@ -16,6 +16,9 @@ export * from './useRerender' export * from './useSearch' export * from './useSimpleStoreSyncedValue' export * from './useSingleSelection' +export * from './useStableEvent' +export * from './useStateMachine' +export * from './useStateMachineBinding' export * from './useStepperHold' export * from './useThrottle' export * from './useTypeAheadSearch' diff --git a/packages/hightide-utils/src/hooks/useControlledState.ts b/packages/hightide-utils/src/hooks/useControlledState.ts index c4e8164f..561fc410 100644 --- a/packages/hightide-utils/src/hooks/useControlledState.ts +++ b/packages/hightide-utils/src/hooks/useControlledState.ts @@ -1,7 +1,7 @@ import type React from 'react' import { useCallback, useEffect, useRef, useState } from 'react' import { resolveSetState } from '../utils/setStateUtils' -import { useEventCallbackStabilizer } from './useEventCallbackStabelizer' +import { useStableEvent } from './useStableEvent' import { useLogOnce } from './useLogOnce' export interface ControlledStateProps { @@ -32,7 +32,7 @@ export const useControlledState = ({ const [internalValue, setInternalValue] = useState(() => defaultValue) const [isControlled] = useState(isEnforcingControlled || controlledValue !== undefined) - const onValueChangeStable = useEventCallbackStabilizer(onValueChange) + const onValueChangeStable = useStableEvent(onValueChange) useLogOnce( 'useControlledState: Attempted to change from controlled to uncontrolled or vice versa.' diff --git a/packages/hightide-utils/src/hooks/useEditCompletable.ts b/packages/hightide-utils/src/hooks/useEditCompletable.ts new file mode 100644 index 00000000..380cec22 --- /dev/null +++ b/packages/hightide-utils/src/hooks/useEditCompletable.ts @@ -0,0 +1,64 @@ +import type { KeyboardEvent } from 'react' +import { useCallback, useRef } from 'react' +import { useDelay } from './useDelay' +import { useStableEvent } from './useStableEvent' + +export type UseEditCompletableProps = { + value: T, + onEditComplete?: (value: T) => void, + delay?: number, + isTimerEnabled?: boolean, + completeKey?: string, +} + +export type UseEditCompletableResult = { + setTimer: () => void, + completeOnKey: (event: KeyboardEvent) => boolean, + completeNow: () => void, +} + +export function useEditCompletable({ + value, + onEditComplete, + delay = 2500, + isTimerEnabled = true, + completeKey = 'Enter', +}: UseEditCompletableProps): UseEditCompletableResult { + const valueRef = useRef(value) + valueRef.current = value + const onEditCompleteStable = useStableEvent(onEditComplete) + + const completeEdit = useCallback(() => { + onEditCompleteStable(valueRef.current) + }, [onEditCompleteStable]) + + const { restartTimer, clearTimer } = useDelay({ + delay, + disabled: !isTimerEnabled, + }) + + const setTimer = useCallback(() => { + restartTimer(completeEdit) + }, [completeEdit, restartTimer]) + + const completeOnKey = useCallback((event: KeyboardEvent) => { + if (event.key !== completeKey || event.shiftKey) { + return false + } + event.preventDefault() + clearTimer() + completeEdit() + return true + }, [clearTimer, completeEdit, completeKey]) + + const completeNow = useCallback(() => { + clearTimer() + completeEdit() + }, [clearTimer, completeEdit]) + + return { + setTimer, + completeOnKey, + completeNow, + } +} diff --git a/packages/hightide-utils/src/hooks/useSearch.ts b/packages/hightide-utils/src/hooks/useSearch.ts index 5f31595f..6c4f66e2 100644 --- a/packages/hightide-utils/src/hooks/useSearch.ts +++ b/packages/hightide-utils/src/hooks/useSearch.ts @@ -1,6 +1,6 @@ import { useMemo } from 'react' import { MultiSearchWithMapping } from '../utils/simpleSearch' -import { useEventCallbackStabilizer } from './useEventCallbackStabelizer' +import { useStableEvent } from './useStableEvent' export interface UseSearchOptions { items: ReadonlyArray, @@ -22,7 +22,7 @@ export function useSearch({ toTags, }: UseSearchOptions): UseSearchReturn { const toTagsResolved = toTags ?? defaultToTags - const toTagsStable = useEventCallbackStabilizer(toTagsResolved) + const toTagsStable = useStableEvent(toTagsResolved) const searchResult = useMemo(() => { const q = searchQuery.trim().toLowerCase() diff --git a/packages/hightide-utils/src/hooks/useSimpleStoreSyncedValue.ts b/packages/hightide-utils/src/hooks/useSimpleStoreSyncedValue.ts index 9018050f..fe330223 100644 --- a/packages/hightide-utils/src/hooks/useSimpleStoreSyncedValue.ts +++ b/packages/hightide-utils/src/hooks/useSimpleStoreSyncedValue.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useState } from 'react' import { useControlledState } from './useControlledState' -import { useEventCallbackStabilizer } from './useEventCallbackStabelizer' +import { useStableEvent } from './useStableEvent' export interface KeyValueStore> { getValue(key: K): TSchema[K], @@ -81,7 +81,7 @@ export function useSimpleStoreSyncedValue({ store.isInitialized, ]) - const encodeStable = useEventCallbackStabilizer(encode) + const encodeStable = useStableEvent(encode) useEffect(() => { if (!store.isInitialized || !hasRead) return diff --git a/packages/hightide-utils/src/hooks/useEventCallbackStabelizer.ts b/packages/hightide-utils/src/hooks/useStableEvent.ts similarity index 91% rename from packages/hightide-utils/src/hooks/useEventCallbackStabelizer.ts rename to packages/hightide-utils/src/hooks/useStableEvent.ts index 975f4d09..edf391b8 100644 --- a/packages/hightide-utils/src/hooks/useEventCallbackStabelizer.ts +++ b/packages/hightide-utils/src/hooks/useStableEvent.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef } from 'react' -export function useEventCallbackStabilizer< +export function useStableEvent< // eslint-disable-next-line @typescript-eslint/no-explicit-any T extends (...args: any[]) => any >( diff --git a/packages/hightide-utils/src/hooks/useStateMachine.ts b/packages/hightide-utils/src/hooks/useStateMachine.ts new file mode 100644 index 00000000..44079596 --- /dev/null +++ b/packages/hightide-utils/src/hooks/useStateMachine.ts @@ -0,0 +1,32 @@ +import { useCallback, useMemo } from 'react' +import { useStableEvent } from './useStableEvent' + +export type StateMachineProps = { + state: S, + transition: (state: S, event: E) => S, + onStateChange?: (state: S) => void, +} + +export type StateMachine = { + state: S, + dispatch: (event: E) => void, +} + +export function useStateMachine({ + state, + transition, + onStateChange, +}: StateMachineProps): StateMachine { + const transitionStable = useStableEvent(transition) + const onStateChangeStable = useStableEvent(onStateChange) + + const dispatch = useCallback((event: E) => { + const nextState = transitionStable(state, event) + onStateChangeStable(nextState) + }, [onStateChangeStable, state, transitionStable]) + + return useMemo(() => ({ + state, + dispatch, + }), [dispatch, state]) +} diff --git a/packages/hightide-utils/src/hooks/useStateMachineBinding.ts b/packages/hightide-utils/src/hooks/useStateMachineBinding.ts new file mode 100644 index 00000000..6462cfb8 --- /dev/null +++ b/packages/hightide-utils/src/hooks/useStateMachineBinding.ts @@ -0,0 +1,56 @@ +import { useCallback, useEffect, useMemo, useRef } from 'react' +import { useStableEvent } from './useStableEvent' + +export type StateMachineBindingProps = { + state: T, + onStateChange?: (state: T) => void, + onValueChange?: (value: V) => void, + inject: (value: V) => Partial, + value?: V, + get: (state: T) => V, +} + +export type StateMachineBindingResult = { + state: T, + onStateChange: (state: T) => void, +} + +export function useStateMachineBinding({ + state, + onStateChange, + onValueChange, + inject, + value, + get, +}: StateMachineBindingProps): StateMachineBindingResult { + const isActive = value !== undefined + const onStateChangeStable = useStableEvent(onStateChange) + const onValueChangeStable = useStableEvent(onValueChange) + const previousValueRef = useRef(value) + + useEffect(() => { + if(value != undefined) + previousValueRef.current = value + }, [value]) + + const nextState = useMemo(() => { + if(isActive) { + return{ ...state, ...inject(value) } + } + return state + }, [inject, isActive, state, value]) + + const handleStateChange = useCallback((newState: T) => { + const nextValue = get(newState) + if (nextValue !== previousValueRef.current) { + onValueChangeStable(nextValue) + } + previousValueRef.current = nextValue + onStateChangeStable(newState) + }, [get, onStateChangeStable, onValueChangeStable]) + + return useMemo(() => ({ + state: nextState, + onStateChange: handleStateChange, + }), [handleStateChange, nextState]) +} diff --git a/packages/hightide-utils/src/hooks/useTypeAheadSearch.ts b/packages/hightide-utils/src/hooks/useTypeAheadSearch.ts index f951bc77..fdf1b6b2 100644 --- a/packages/hightide-utils/src/hooks/useTypeAheadSearch.ts +++ b/packages/hightide-utils/src/hooks/useTypeAheadSearch.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef } from 'react' -import { useEventCallbackStabilizer } from './useEventCallbackStabelizer' +import { useStableEvent } from './useStableEvent' export interface UseTypeAheadSearchOptions { options: ReadonlyArray, @@ -27,8 +27,8 @@ export function useTypeAheadSearch({ const timeoutRef = useRef | null>(null) const toString = toStringProp ?? defaultToString - const toStringStable = useEventCallbackStabilizer(toString) - const onResultChangeStable = useEventCallbackStabilizer(onResultChange) + const toStringStable = useStableEvent(toString) + const onResultChangeStable = useStableEvent(onResultChange) const reset = useCallback(() => { if (timeoutRef.current) { diff --git a/packages/hightide-utils/src/interfaces/index.ts b/packages/hightide-utils/src/interfaces/index.ts new file mode 100644 index 00000000..0cffea4e --- /dev/null +++ b/packages/hightide-utils/src/interfaces/index.ts @@ -0,0 +1,2 @@ +export * from './input' +export * from './state-manager' diff --git a/packages/hightide-utils/src/interfaces/input.ts b/packages/hightide-utils/src/interfaces/input.ts new file mode 100644 index 00000000..9e72a0ca --- /dev/null +++ b/packages/hightide-utils/src/interfaces/input.ts @@ -0,0 +1,26 @@ +import type { StateManagerProps } from './state-manager' + +export interface InputState { value: unknown } +export interface InputStateEvent { type: string } +export interface InputConfig { + isInvalid: boolean, + isDisabled: boolean, + isReadOnly: boolean, + isRequired: boolean, +} +export interface InputContextValue { + state: T, + dispatch: (event: E) => void, + config: C, +} + +export interface ControllableInputProps extends Partial { + value?: T, + initialValue?: T, + onValueChange?: (value: T) => void, + onStateEvent?: (event: E) => void, +} + +export interface InputStateMangerProps extends + Partial, + StateManagerProps {} \ No newline at end of file diff --git a/packages/hightide-utils/src/interfaces/state-manager.ts b/packages/hightide-utils/src/interfaces/state-manager.ts new file mode 100644 index 00000000..e15701fb --- /dev/null +++ b/packages/hightide-utils/src/interfaces/state-manager.ts @@ -0,0 +1,5 @@ +export interface StateManagerProps { + state: T, + onStateChange?: (state: T) => void, + onStateEvent?: (event: E) => void, +} diff --git a/packages/hightide-utils/src/utils/README.md b/packages/hightide-utils/src/utils/README.md index f895c00d..54b924ee 100644 --- a/packages/hightide-utils/src/utils/README.md +++ b/packages/hightide-utils/src/utils/README.md @@ -12,7 +12,6 @@ See [conventions](../../conventions/README.md) for how Utils objects, deprecatio | [curve.ts](./curve.ts) | `CurveBuilderUtil` | Easing and rate curves including cubic bezier and exponential builders | | [date.ts](./date.ts) | `DateUtils` | Date/time parsing, formatting, time zones, calendars, and duration arithmetic | | [duration.ts](./duration.ts) | `DurationUtils` | Immutable duration value object with date arithmetic | -| [emailValidation.ts](./emailValidation.ts) | `EmailValidationUtils` | Email address format validation | | [loopingArray.ts](./loopingArray.ts) | `LoopingArrayUtils` | Circular array position, distance, and direction calculations | | [match.ts](./match.ts) | `MatchUtils` | Key lookup against a record map | | [math.ts](./math.ts) | `MathUtil` | Clamping, step ranges, and looping numeric bounds | diff --git a/packages/hightide-utils/src/utils/componentState.ts b/packages/hightide-utils/src/utils/componentState.ts new file mode 100644 index 00000000..d00ef60f --- /dev/null +++ b/packages/hightide-utils/src/utils/componentState.ts @@ -0,0 +1,48 @@ +import type { EnumUtilsType } from './enum' + +const loadingStateValues = ['idle', 'loading', 'blocked', 'processing'] as const +export type LoadingState = (typeof loadingStateValues)[number] +const allowedLoadingStateValues: ReadonlySet = new Set(loadingStateValues) +function isLoadingStateValue(value: unknown): value is LoadingState { + if (typeof value !== 'string') return false + return allowedLoadingStateValues.has(value) +} +export const LoadingStateUtils: EnumUtilsType = { + values: loadingStateValues, + set: allowedLoadingStateValues, + isValue: isLoadingStateValue, +} + +const interactiveStateValues = ['hover', 'pressed', 'focus', 'focus-visible', 'disabled'] as const +export type InteractiveState = (typeof interactiveStateValues)[number] +const allowedInteractiveStateValues: ReadonlySet = new Set(interactiveStateValues) +function isInteractiveStateValue(value: unknown): value is InteractiveState { + if (typeof value !== 'string') return false + return allowedInteractiveStateValues.has(value) +} +export const InteractiveStateUtils: EnumUtilsType = { + values: interactiveStateValues, + set: allowedInteractiveStateValues, + isValue: isInteractiveStateValue, +} + +const dataInputStateValues = [ + 'readonly', + 'invalid', + 'hover', + 'pressed', + 'focus', + 'focus-visible', + 'disabled', +] as const +export type DataInputState = (typeof dataInputStateValues)[number] +const allowedDataInputStateValues: ReadonlySet = new Set(dataInputStateValues) +function isDataInputStateValue(value: unknown): value is DataInputState { + if (typeof value !== 'string') return false + return allowedDataInputStateValues.has(value) +} +export const DataInputStateUtils: EnumUtilsType = { + values: dataInputStateValues, + set: allowedDataInputStateValues, + isValue: isDataInputStateValue, +} diff --git a/packages/hightide-utils/src/utils/emailValidation.ts b/packages/hightide-utils/src/utils/emailValidation.ts deleted file mode 100644 index a7d9dd16..00000000 --- a/packages/hightide-utils/src/utils/emailValidation.ts +++ /dev/null @@ -1,10 +0,0 @@ -const validateEmail = (email: string): boolean => { - return /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(email) -} - -export const EmailValidationUtils = { - validateEmail, -} - -/** @deprecated Use EmailValidationUtils.validateEmail instead. */ -export { validateEmail } diff --git a/packages/hightide-utils/src/utils/form-validation/FormValidationErrorType.ts b/packages/hightide-utils/src/utils/form-validation/FormValidationErrorType.ts new file mode 100644 index 00000000..568a4a50 --- /dev/null +++ b/packages/hightide-utils/src/utils/form-validation/FormValidationErrorType.ts @@ -0,0 +1,57 @@ +const stringFormValidationErrorTypeValues = [ + 'notEmpty', + 'invalidEmail', + 'tooShort', + 'tooLong', + 'outOfRangeString', +] as const + +const numberFormValidationErrorTypeValues = [ + 'notEmpty', + 'outOfRangeNumber', +] as const + +const selectionFormValidationErrorTypeValues = [ + 'notEmpty', + 'tooFewSelectionItems', + 'tooManySelectionItems', + 'outOfRangeSelectionItems', +] as const + +const formValidationErrorTypeValues = [ + 'notEmpty', + 'invalidEmail', + 'tooShort', + 'tooLong', + 'outOfRangeString', + 'outOfRangeNumber', + 'tooFewSelectionItems', + 'tooManySelectionItems', + 'outOfRangeSelectionItems', +] as const + +export type StringFormValidationErrorType = (typeof stringFormValidationErrorTypeValues)[number] + +export type NumberFormValidationErrorType = (typeof numberFormValidationErrorTypeValues)[number] + +export type SelectionFormValidationErrorType = (typeof selectionFormValidationErrorTypeValues)[number] + +export type FormValidationErrorType = (typeof formValidationErrorTypeValues)[number] + +export type FormValidationResult = FormValidationErrorType | undefined + +const allowedFormValidationErrorTypeValues: ReadonlySet = new Set(formValidationErrorTypeValues) + +function isFormValidationErrorTypeValue(value: unknown): value is FormValidationErrorType { + if (typeof value !== 'string') return false + return allowedFormValidationErrorTypeValues.has(value) +} + +export const FormValidationErrorTypeUtils = { + array: formValidationErrorTypeValues, + string: stringFormValidationErrorTypeValues, + number: numberFormValidationErrorTypeValues, + selection: selectionFormValidationErrorTypeValues, + set: allowedFormValidationErrorTypeValues, + typeCheck: isFormValidationErrorTypeValue, +} diff --git a/packages/hightide-utils/src/utils/form-validation/FormValidationUtils.ts b/packages/hightide-utils/src/utils/form-validation/FormValidationUtils.ts new file mode 100644 index 00000000..543aafb8 --- /dev/null +++ b/packages/hightide-utils/src/utils/form-validation/FormValidationUtils.ts @@ -0,0 +1,23 @@ +import { notEmpty as numberNotEmpty, range } from './number' +import { bounds, notEmpty as selectionNotEmpty } from './selection' +import { email, length, notEmpty as stringNotEmpty } from './string' +import { mapNumberTranslation, mapSelectionTranslation, mapStringTranslation } from './translation' + +export const FormValidationUtils = { + string: { + notEmpty: stringNotEmpty, + email, + length, + mapTranslation: mapStringTranslation, + }, + number: { + notEmpty: numberNotEmpty, + range, + mapTranslation: mapNumberTranslation, + }, + selection: { + notEmpty: selectionNotEmpty, + bounds, + mapTranslation: mapSelectionTranslation, + }, +} diff --git a/packages/hightide-utils/src/utils/form-validation/bounds.ts b/packages/hightide-utils/src/utils/form-validation/bounds.ts new file mode 100644 index 00000000..e28ca0f2 --- /dev/null +++ b/packages/hightide-utils/src/utils/form-validation/bounds.ts @@ -0,0 +1,21 @@ +export type FormValidationBounds = [number | undefined, number | undefined] + +export type BoundsValidationResult = 'lower' | 'upper' | 'range' | 'none' + +export const boundsValidation = (length: number | undefined, bounds: FormValidationBounds): BoundsValidationResult => { + const [min, max] = bounds + + if (min !== undefined && max !== undefined && (length === undefined || length < min || length > max)) { + return 'range' + } + + if (min !== undefined && (length === undefined || length < min)) { + return 'lower' + } + + if (max !== undefined && length !== undefined && length > max) { + return 'upper' + } + + return 'none' +} diff --git a/packages/hightide-utils/src/utils/form-validation/index.ts b/packages/hightide-utils/src/utils/form-validation/index.ts new file mode 100644 index 00000000..c50aa386 --- /dev/null +++ b/packages/hightide-utils/src/utils/form-validation/index.ts @@ -0,0 +1,11 @@ +export { FormValidationUtils } from './FormValidationUtils' +export { FormValidationErrorTypeUtils } from './FormValidationErrorType' +export type { FormValidationBounds } from './bounds' +export type { + FormValidationErrorType, + FormValidationResult, + NumberFormValidationErrorType, + SelectionFormValidationErrorType, + StringFormValidationErrorType, +} from './FormValidationErrorType' +export type { FormValidationTranslation, FormValidationTranslationInput, FormValidationTranslationParameters } from './translation' diff --git a/packages/hightide-utils/src/utils/form-validation/number.ts b/packages/hightide-utils/src/utils/form-validation/number.ts new file mode 100644 index 00000000..46aee666 --- /dev/null +++ b/packages/hightide-utils/src/utils/form-validation/number.ts @@ -0,0 +1,21 @@ +import { boundsValidation, type FormValidationBounds } from './bounds' +import type { FormValidationResult } from './FormValidationErrorType' + +export const notEmpty = (value: number | undefined | null): FormValidationResult => { + if (value === undefined || value === null || Number.isNaN(value)) { + return 'notEmpty' + } +} + +export const range = (value: number | undefined | null, bounds: FormValidationBounds): FormValidationResult => { + if (value === undefined || value === null || Number.isNaN(value)) { + return undefined + } + + const result = boundsValidation(value, bounds) + if (result === 'none') { + return undefined + } + + return 'outOfRangeNumber' +} diff --git a/packages/hightide-utils/src/utils/form-validation/selection.ts b/packages/hightide-utils/src/utils/form-validation/selection.ts new file mode 100644 index 00000000..4f1e8c02 --- /dev/null +++ b/packages/hightide-utils/src/utils/form-validation/selection.ts @@ -0,0 +1,18 @@ +import { boundsValidation, type FormValidationBounds } from './bounds' +import type { FormValidationResult } from './FormValidationErrorType' + +export const notEmpty = (value: readonly unknown[] | undefined): FormValidationResult => { + if (!value || value.length === 0) { + return 'notEmpty' + } +} + +export const bounds = (value: readonly unknown[] | undefined, limits: FormValidationBounds): FormValidationResult => { + const mapping: Record, FormValidationResult> = { + range: 'outOfRangeSelectionItems', + lower: 'tooFewSelectionItems', + upper: 'tooManySelectionItems', + none: undefined, + } + return mapping[boundsValidation(value?.length, limits)] +} diff --git a/packages/hightide-utils/src/utils/form-validation/string.ts b/packages/hightide-utils/src/utils/form-validation/string.ts new file mode 100644 index 00000000..31a40a6c --- /dev/null +++ b/packages/hightide-utils/src/utils/form-validation/string.ts @@ -0,0 +1,26 @@ +import { boundsValidation, type FormValidationBounds } from './bounds' +import type { FormValidationResult } from './FormValidationErrorType' + +const emailPattern = /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i + +export const notEmpty = (value: string | undefined): FormValidationResult => { + if (!value) { + return 'notEmpty' + } +} + +export const email = (value: string | undefined): FormValidationResult => { + if (!value || !emailPattern.test(value)) { + return 'invalidEmail' + } +} + +export const length = (value: string | undefined, bounds: FormValidationBounds): FormValidationResult => { + const mapping: Record, FormValidationResult> = { + range: 'outOfRangeString', + lower: 'tooShort', + upper: 'tooLong', + none: undefined, + } + return mapping[boundsValidation(value?.length, bounds)] +} diff --git a/packages/hightide-utils/src/utils/form-validation/translation.ts b/packages/hightide-utils/src/utils/form-validation/translation.ts new file mode 100644 index 00000000..0c84aa6c --- /dev/null +++ b/packages/hightide-utils/src/utils/form-validation/translation.ts @@ -0,0 +1,148 @@ +import type { HightideTranslationEntries } from '../../i18n/translations' +import type { + FormValidationErrorType, + NumberFormValidationErrorType, + SelectionFormValidationErrorType, + StringFormValidationErrorType +} from './FormValidationErrorType' + +type TranslationParameters = + HightideTranslationEntries[K] extends (values: infer Values) => string + ? Values + : undefined + +type FormValidationTranslationKey = + `validationError${Capitalize}` extends keyof HightideTranslationEntries + ? `validationError${Capitalize}` + : never + +export type FormValidationTranslationParameters = + TranslationParameters> + +type OptionalTranslationParameters = + FormValidationTranslationParameters extends undefined + ? undefined + : Partial> + +export type FormValidationTranslationInput = { + type: T, + parameter?: OptionalTranslationParameters, +} + +export type FormValidationTranslation = { + key: FormValidationTranslationKey, + parameter: FormValidationTranslationParameters, +} + +const formValidationTranslationKeys = { + notEmpty: 'validationErrorNotEmpty', + invalidEmail: 'validationErrorInvalidEmail', + tooLong: 'validationErrorTooLong', + tooShort: 'validationErrorTooShort', + outOfRangeString: 'validationErrorOutOfRangeString', + outOfRangeNumber: 'validationErrorOutOfRangeNumber', + outOfRangeSelectionItems: 'validationErrorOutOfRangeSelectionItems', + tooFewSelectionItems: 'validationErrorTooFewSelectionItems', + tooManySelectionItems: 'validationErrorTooManySelectionItems', +} as const satisfies { [T in FormValidationErrorType]: FormValidationTranslationKey } + +const numberParameterDefaults = { + min: 0, + max: 0, +} + +const fillNumberParameter = ( + required: T, + provided: Partial | undefined +): T => { + const filled = { ...required } + + if (!provided) { + return filled + } + + for (const key of Object.keys(required) as (keyof T)[]) { + const value = provided[key] + if (value !== undefined) { + filled[key] = value + } + } + + return filled +} + +const toTranslation = ( + type: T, + parameter: FormValidationTranslationParameters +): FormValidationTranslation => ({ + key: formValidationTranslationKeys[type], + parameter, +}) as unknown as FormValidationTranslation + +const mapWithNumbers = ( + type: T, + provided: { min?: number, max?: number } | undefined, + required: { min?: number, max?: number } +): FormValidationTranslation => { + return toTranslation(type, fillNumberParameter(required, provided) as FormValidationTranslationParameters) +} + +export const mapStringTranslation = ( + error: FormValidationTranslationInput +): FormValidationTranslation => { + const provided = error.parameter as { min?: number, max?: number } | undefined + + switch (error.type) { + case 'notEmpty': + case 'invalidEmail': + return toTranslation(error.type, undefined as FormValidationTranslationParameters) + case 'tooShort': + return mapWithNumbers(error.type, provided, { min: numberParameterDefaults.min }) + case 'tooLong': + return mapWithNumbers(error.type, provided, { max: numberParameterDefaults.max }) + case 'outOfRangeString': + return mapWithNumbers(error.type, provided, numberParameterDefaults) + default: { + const unsupported: never = error.type + throw new Error(`Unsupported string validation error type: ${unsupported}`) + } + } +} + +export const mapNumberTranslation = ( + error: FormValidationTranslationInput +): FormValidationTranslation => { + const provided = error.parameter as { min?: number, max?: number } | undefined + + switch (error.type) { + case 'notEmpty': + return toTranslation(error.type, undefined as FormValidationTranslationParameters) + case 'outOfRangeNumber': + return mapWithNumbers(error.type, provided, numberParameterDefaults) + default: { + const unsupported: never = error.type + throw new Error(`Unsupported number validation error type: ${unsupported}`) + } + } +} + +export const mapSelectionTranslation = ( + error: FormValidationTranslationInput +): FormValidationTranslation => { + const provided = error.parameter as { min?: number, max?: number } | undefined + + switch (error.type) { + case 'notEmpty': + return toTranslation(error.type, undefined as FormValidationTranslationParameters) + case 'tooFewSelectionItems': + return mapWithNumbers(error.type, provided, { min: numberParameterDefaults.min }) + case 'tooManySelectionItems': + return mapWithNumbers(error.type, provided, { max: numberParameterDefaults.max }) + case 'outOfRangeSelectionItems': + return mapWithNumbers(error.type, provided, numberParameterDefaults) + default: { + const unsupported: never = error.type + throw new Error(`Unsupported selection validation error type: ${unsupported}`) + } + } +} diff --git a/packages/hightide-utils/src/utils/index.ts b/packages/hightide-utils/src/utils/index.ts index 1cf95af1..3c7d5847 100644 --- a/packages/hightide-utils/src/utils/index.ts +++ b/packages/hightide-utils/src/utils/index.ts @@ -1,10 +1,11 @@ export * from './array' export * from './bagFunctions' export * from './builder' +export * from './componentState' export * from './curve' export * from './date' export * from './duration' -export * from './emailValidation' +export * from './form-validation' export * from './enum' export * from './localization' export * from './loopingArray' diff --git a/packages/hightide-utils/src/utils/react.ts b/packages/hightide-utils/src/utils/react.ts index 987f627e..57077ebe 100644 --- a/packages/hightide-utils/src/utils/react.ts +++ b/packages/hightide-utils/src/utils/react.ts @@ -9,7 +9,7 @@ function assignForwardRef(element: T | null, ref?: ForwardedRef) { } } -function assingRefsBuilder(refs: ForwardedRef[]) : (el: T | null) => void { +function assingRefsBuilder(refs: (ForwardedRef | undefined)[]) : (el: T | null) => void { return (element) => { refs.forEach(ref => { assignForwardRef(element, ref) diff --git a/packages/hightide-utils/tsup.config.ts b/packages/hightide-utils/tsup.config.ts index d4d41668..615543f4 100644 --- a/packages/hightide-utils/tsup.config.ts +++ b/packages/hightide-utils/tsup.config.ts @@ -7,8 +7,10 @@ export default defineConfig({ 'context/translation/index': 'src/context/translation/index.ts', 'context/theme/index': 'src/context/theme/index.ts', 'context/debug/index': 'src/context/debug/index.ts', + 'form/index': 'src/form/index.ts', 'hooks/index': 'src/hooks/index.ts', 'i18n/index': 'src/i18n/index.ts', + 'interfaces/index': 'src/interfaces/index.ts', 'utils/index': 'src/utils/index.ts', }, format: ['cjs', 'esm'], @@ -22,6 +24,7 @@ export default defineConfig({ external: [ 'react', '@helpwave/internationalization', + 'zustand', ], cjsInterop: true, }) diff --git a/packages/hightide-web/.storybook/main.ts b/packages/hightide-web/.storybook/main.ts index 5c677c75..184510e2 100644 --- a/packages/hightide-web/.storybook/main.ts +++ b/packages/hightide-web/.storybook/main.ts @@ -1,6 +1,6 @@ import path from 'node:path' import { fileURLToPath } from 'node:url' -import type { StorybookConfig } from '@storybook/nextjs-vite' +import type { StorybookConfig } from '@storybook/react-vite' const dirname = path.dirname(fileURLToPath(import.meta.url)) diff --git a/packages/hightide-web/CHANGELOG.md b/packages/hightide-web/CHANGELOG.md index 690eba30..32376416 100644 --- a/packages/hightide-web/CHANGELOG.md +++ b/packages/hightide-web/CHANGELOG.md @@ -12,19 +12,103 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - `ExpandableHeader` `triggerProps` for the header toggle button - `ExpandableContent` `isClosingOnClick` to collapse when the content is clicked - `NavigationItemList` `onClose` so Escape on items can close the parent menu +- Subpath package exports: `@helpwave/hightide/components`, `@helpwave/hightide/global-contexts`, `@helpwave/hightide/hooks`, and `@helpwave/hightide/utils`, each with types +- `Pressable` for colored buttons, with `coloringStyle` (`filled` | `foreground`), `colorVariant` (`normal` | `tonal` | `transparent`), `bordered`, `elevated`, and `processingIndicator` +- `Button` and `IconButton` `variant`: `elevated`, `filled`, `tonal`, `outlined` (`Button` only), and `foreground` +- `surfaceInverse` button color +- `Icon` for a sized icon element (`xs` through `xl`) +- `Divider` with `direction` `horizontal` or `vertical` +- `ListItem`, `ListActionItem`, and `ListNavigationItem` (`href`, `onClick`, or `LinkComponent`; external links show an external icon) +- Compound `Expandable`: `Expandable.Root`, `Expandable.Trigger` (render prop with toggle props), `Expandable.Content`, `Expandable.Context`, and `Expandable.Consumer` +- `ExpandableSection`, `ExpandableSectionHeader`, and `ExpandableSectionContent` for the previous combined expandable layout +- Compound `Avatar`: `Avatar.Root`, `Avatar.Image`, `Avatar.Fallback`, `Avatar.Name`, `Avatar.StatusIndicator`, and `Avatar.Context`. The combined `Avatar` accepts `status`, `hasStatusIndicator`, and slot props +- Compound `AvatarGroup`: `AvatarGroup.Container`, `AvatarGroup.Overlap`, and `AvatarGroup.AdditionalText` +- Compound `WheelPicker`: `WheelPicker.Root`, `WheelPicker.Option`, `WheelPicker.Bar`, and `WheelPicker.Context`, with `value` / `defaultValue`, `onValueChange`, `onLoop`, `isLooping`, and `visibleRows` +- `DateWheelPicker`, `TimeWheelPicker`, and `DateTimeWheelPicker` +- `ChatAttachmentMessageBubble`, a `ChatMessageBubble` specialization for a file name, metadata, and optional download action +- `ColoringUtils` with `build` (defaults: mode `static`, color variant `normal`, coloring style `filled`) and setters `dataColor`, `dataColoringMode`, `dataColorVariant`, `dataColoringStyle`, `dataBordered`, and `dataElevated` +- Coloring attributes `data-coloring-mode` (`static` | `interactive`), `data-color`, `data-color-variant`, `data-coloring-style`, `data-bordered`, and `data-elevated`. Hover (8%) and press (16%) state layers apply only when mode is `interactive` +- `--coloring-state-hover` and `--coloring-state-press` +- `--appearance-subtle` (`70%`) +- `text-description`, which sets the text color from the active foreground and background +- Added a `ModalPanel` component for the modal panel + +### Removed + +- `useTranslatedValidators`. Form validation returns error codes from `FormValidationUtils` without translating them ### Changed +- Form creation and validation use `@helpwave/hightide-utils/form`. `FormField` keeps its current props and bag - Click and keyboard handlers no longer call `event.stopPropagation` (`Button`, `IconButton`, `Select`, `MultiSelect`, `FileInput`, `Navigation`, `Expandable`, and `PropsUtil` helpers) - `Button` no longer stops click bubbling; `allowClickEventPropagation` has no effect - `Expandable` header is a ` + ) : ( +
+ {content} +
+ )} + + ) +} diff --git a/packages/hightide-web/src/components/chat/visualization/ChatMessageBubble.tsx b/packages/hightide-web/src/components/chat/visualization/ChatMessageBubble.tsx new file mode 100644 index 00000000..5dc740ee --- /dev/null +++ b/packages/hightide-web/src/components/chat/visualization/ChatMessageBubble.tsx @@ -0,0 +1,84 @@ +import { useMemo, type HTMLAttributes, type ReactNode } from 'react' +import clsx from 'clsx' +import { Check, CheckCheck, Clock } from 'lucide-react' +import { DateUtils } from '@helpwave/hightide-utils/utils' +import { useDateTimeFormat, useLocalization } from '../../../global-contexts/localization/forward-exports' +import { Icon } from '../../visualization/Icon' +import { ColoringUtils } from '../../../utils/coloring' + +export const chatMessageDirections = ['incoming', 'outgoing'] as const + +export type ChatMessageDirection = typeof chatMessageDirections[number] + +export const chatMessageStatuses = ['sent', 'sending', 'received', 'read'] as const + +export type ChatMessageStatus = typeof chatMessageStatuses[number] + +export type ChatMessageBubbleProps = Omit, 'children'> & { + direction: ChatMessageDirection, + timestamp?: Date, + status?: ChatMessageStatus, + children?: ReactNode, +} + +const statusIcon = (status: ChatMessageStatus) => ( + status === 'sending' + ? Clock + : status === 'sent' + ? Check + : CheckCheck +) + +export const ChatMessageBubble = ({ + direction, + timestamp, + status, + children, + className, + ...props +}: ChatMessageBubbleProps) => { + const { locale } = useLocalization() + const { is24HourFormat, timeZone } = useDateTimeFormat() + const formattedTimestamp = useMemo(() => ( + timestamp === undefined + ? undefined + : DateUtils.formatAbsolute(timestamp, locale, 'time', { timeZone, is24HourFormat }) + ), [timestamp, locale, timeZone, is24HourFormat]) + const hasMetaData = formattedTimestamp != null || status != null + + return ( +
+
+ {typeof children === 'string' || typeof children === 'number' ? ( + {children} + ) : ( + children + )} +
+ {hasMetaData && ( + + {status != null && ( + + + + )} + {formattedTimestamp != null && ( + {formattedTimestamp} + )} + + )} +
+ ) +} diff --git a/packages/hightide-web/src/components/chat/visualization/ChatSystemLine.tsx b/packages/hightide-web/src/components/chat/visualization/ChatSystemLine.tsx new file mode 100644 index 00000000..e142ceef --- /dev/null +++ b/packages/hightide-web/src/components/chat/visualization/ChatSystemLine.tsx @@ -0,0 +1,31 @@ +import type { ElementType, HTMLAttributes } from 'react' +import clsx from 'clsx' +import { CheckCheck } from 'lucide-react' +import { Icon } from '../../visualization/Icon' +import type { ChipColor } from '../../visualization/Chip' +import { ColoringUtils } from '../../../utils/coloring' + +export type ChatSystemLineProps = HTMLAttributes & { + icon?: ElementType, + color?: ChipColor, +} + +export const ChatSystemLine = ({ + icon = CheckCheck, + color = 'primary', + children, + ...props +}: ChatSystemLineProps) => { + return ( +
+ + {children} +
+ ) +} diff --git a/packages/hightide-web/src/components/chat/visualization/index.ts b/packages/hightide-web/src/components/chat/visualization/index.ts new file mode 100644 index 00000000..47e7aae2 --- /dev/null +++ b/packages/hightide-web/src/components/chat/visualization/index.ts @@ -0,0 +1,3 @@ +export * from './ChatAttachmentMessageBubble' +export * from './ChatMessageBubble' +export * from './ChatSystemLine' diff --git a/packages/hightide-web/src/components/data-input/Checkbox/Checkbox.tsx b/packages/hightide-web/src/components/data-input/Checkbox/Checkbox.tsx new file mode 100644 index 00000000..9dabcd87 --- /dev/null +++ b/packages/hightide-web/src/components/data-input/Checkbox/Checkbox.tsx @@ -0,0 +1,86 @@ +import type { ComponentRef, Ref } from 'react' +import { useCallback } from 'react' +import type { ControllableInputProps } from '@helpwave/hightide-utils/interfaces' +import { useControlledState, useStateMachineBinding } from '@helpwave/hightide-utils/hooks' +import { ReactUtils } from '@helpwave/hightide-utils/utils' +import { CheckboxIcon, type CheckboxIconProps } from './CheckboxIcon' +import { CheckboxStateManager } from './CheckboxStateManager' +import { CheckboxTrigger, type CheckboxSize, type CheckboxTriggerProps } from './CheckboxTrigger' +import type { CheckboxEvent, CheckboxState } from './CheckboxState' + +export type CheckboxProps = ControllableInputProps + & { + indeterminate?: boolean, + size?: CheckboxSize, + alwaysShowCheckIcon?: boolean, + isRounded?: boolean, + triggerRef?: Ref>, + triggerProps?: CheckboxTriggerProps, + iconRef?: Ref>, + iconProps?: CheckboxIconProps, + } + +const CheckboxComponent = ({ + value, + initialValue, + onValueChange, + onStateEvent, + isInvalid, + isDisabled, + isReadOnly, + isRequired, + indeterminate = false, + size = 'md', + alwaysShowCheckIcon = false, + isRounded = false, + triggerRef, + triggerProps, + iconRef, + iconProps, +}: CheckboxProps) => { + const [state, setState] = useControlledState({ + defaultValue: { value: initialValue ?? false }, + }) + const valueBinding = useStateMachineBinding({ + state, + onStateChange: setState, + onValueChange, + value, + inject: useCallback((next: boolean) => ({ value: next }), []), + get: useCallback((current: CheckboxState) => current.value, []), + }) + + return ( + + + + + + ) +} + +export const Checkbox = Object.assign(CheckboxComponent, { + StateManager: CheckboxStateManager, + Trigger: CheckboxTrigger, + Icon: CheckboxIcon, +}) diff --git a/packages/hightide-web/src/components/data-input/Checkbox/CheckboxContext.tsx b/packages/hightide-web/src/components/data-input/Checkbox/CheckboxContext.tsx new file mode 100644 index 00000000..753b92f4 --- /dev/null +++ b/packages/hightide-web/src/components/data-input/Checkbox/CheckboxContext.tsx @@ -0,0 +1,13 @@ +import { createContext, useContext } from 'react' +import type { InputContextValue } from '@helpwave/hightide-utils/interfaces' +import type { CheckboxEvent, CheckboxState } from './CheckboxState' + +export type CheckboxContextValue = InputContextValue + +export const CheckboxContext = createContext(null) + +export function useCheckboxContext(): CheckboxContextValue { + const context = useContext(CheckboxContext) + if (!context) throw new Error('useCheckboxContext must be used inside a Checkbox.StateManager') + return context +} diff --git a/packages/hightide-web/src/components/data-input/Checkbox/CheckboxIcon.tsx b/packages/hightide-web/src/components/data-input/Checkbox/CheckboxIcon.tsx new file mode 100644 index 00000000..9f2808c5 --- /dev/null +++ b/packages/hightide-web/src/components/data-input/Checkbox/CheckboxIcon.tsx @@ -0,0 +1,51 @@ +import clsx from 'clsx' +import { Check, Minus } from 'lucide-react' +import type { HTMLAttributes } from 'react' +import { forwardRef } from 'react' +import type { IconSize } from '../../visualization/Icon' +import { Icon } from '../../visualization/Icon' +import { useCheckboxContext } from './CheckboxContext' +import type { CheckboxSize } from './CheckboxTrigger' + +export type CheckboxIconProps = HTMLAttributes & { + indeterminate?: boolean, + size?: CheckboxSize, + alwaysShowCheckIcon?: boolean, +} + +const indicatorSizeFor = (size: CheckboxSize): IconSize => { + if (size === 'lg') { + return 'md' + } + return 'sm' +} + +export const CheckboxIcon = forwardRef(function CheckboxIcon({ + indeterminate = false, + size = 'md', + alwaysShowCheckIcon = false, + ...props +}, forwardedRef) { + const { state } = useCheckboxContext() + const showsIndeterminate = indeterminate + const showsCheck = !indeterminate && (alwaysShowCheckIcon || state.value) + + if (!showsIndeterminate && !showsCheck) { + return null + } + + return ( + + + + ) +}) diff --git a/packages/hightide-web/src/components/data-input/Checkbox/CheckboxState.ts b/packages/hightide-web/src/components/data-input/Checkbox/CheckboxState.ts new file mode 100644 index 00000000..bc1fbd3b --- /dev/null +++ b/packages/hightide-web/src/components/data-input/Checkbox/CheckboxState.ts @@ -0,0 +1,14 @@ +export type CheckboxState = { + value: boolean, +} + +export type CheckboxEvent = { + type: 'toggle', +} + +export const checkboxStateTransition = (state: CheckboxState, event: CheckboxEvent): CheckboxState => { + switch (event.type) { + case 'toggle': + return { value: !state.value } + } +} diff --git a/packages/hightide-web/src/components/data-input/Checkbox/CheckboxStateManager.tsx b/packages/hightide-web/src/components/data-input/Checkbox/CheckboxStateManager.tsx new file mode 100644 index 00000000..6c2932d2 --- /dev/null +++ b/packages/hightide-web/src/components/data-input/Checkbox/CheckboxStateManager.tsx @@ -0,0 +1,53 @@ +import type { ReactNode } from 'react' +import { useCallback, useMemo } from 'react' +import type { InputStateMangerProps } from '@helpwave/hightide-utils/interfaces' +import { useStableEvent, useStateMachine } from '@helpwave/hightide-utils/hooks' +import { CheckboxContext, type CheckboxContextValue } from './CheckboxContext' +import { checkboxStateTransition, type CheckboxEvent, type CheckboxState } from './CheckboxState' + +export type CheckboxStateManagerProps = InputStateMangerProps + & { + children?: ReactNode, + } + +export function CheckboxStateManager({ + state, + onStateChange, + onStateEvent, + isInvalid = false, + isDisabled = false, + isReadOnly = false, + isRequired = false, + children, +}: CheckboxStateManagerProps) { + const machine = useStateMachine({ + state, + transition: checkboxStateTransition, + onStateChange, + }) + + const onStateEventStable = useStableEvent(onStateEvent) + const dispatch = useCallback((event: CheckboxEvent) => { + machine.dispatch(event) + onStateEventStable(event) + }, [machine, onStateEventStable]) + + const config = useMemo(() => ({ + isInvalid, + isDisabled, + isReadOnly, + isRequired, + }), [isDisabled, isInvalid, isReadOnly, isRequired]) + + const contextValue = useMemo(() => ({ + state: machine.state, + dispatch, + config, + }), [config, dispatch, machine.state]) + + return ( + + {children} + + ) +} diff --git a/packages/hightide-web/src/components/data-input/Checkbox/CheckboxTrigger.tsx b/packages/hightide-web/src/components/data-input/Checkbox/CheckboxTrigger.tsx new file mode 100644 index 00000000..795296d8 --- /dev/null +++ b/packages/hightide-web/src/components/data-input/Checkbox/CheckboxTrigger.tsx @@ -0,0 +1,72 @@ +import clsx from 'clsx' +import type { HTMLAttributes } from 'react' +import { forwardRef } from 'react' +import { ColoringUtils } from '../../../utils/coloring' +import { PropsUtil } from '../../../utils/propsUtil' +import { useCheckboxContext } from './CheckboxContext' + +export type CheckboxSize = 'sm' | 'md' | 'lg' | null + +export type CheckboxTriggerProps = HTMLAttributes & { + indeterminate?: boolean, + size?: CheckboxSize, + isRounded?: boolean, +} + +export const CheckboxTrigger = forwardRef(function CheckboxTrigger({ + indeterminate = false, + size = 'md', + isRounded = false, + children, + ...props +}, forwardedRef) { + const { state, dispatch, config } = useCheckboxContext() + // TODO checkboxes do not have a readonly state, consider removing it or adding a description for accessibility + const { isInvalid, isDisabled, isReadOnly, isRequired } = config + const isInteractive = !isDisabled && !isReadOnly + + return ( +
{ + props.onClick?.(event) + if (isInteractive) { + dispatch({ type: 'toggle' }) + } + }} + onKeyDown={(event) => { + props.onKeyDown?.(event) + if (!isInteractive) { + return + } + if (event.key === ' ') { + event.preventDefault() + dispatch({ type: 'toggle' }) + } + }} + data-checked={indeterminate ? 'indeterminate' : String(state.value)} + data-size={size ?? undefined} + data-rounded={isRounded ? '' : undefined} + {...PropsUtil.dataAttributes.interactionStates({ + invalid: isInvalid, + disabled: isDisabled, + readOnly: isReadOnly, + required: isRequired, + })} + role="checkbox" + tabIndex={isDisabled ? -1 : 0} + aria-checked={indeterminate ? 'mixed' : state.value} + {...PropsUtil.aria.interactionStates({ + invalid: isInvalid, + disabled: isDisabled, + readOnly: isReadOnly, + required: isRequired, + }, props)} + {...ColoringUtils.dataColoringMode('interactive')} + className={clsx('checkbox', props.className)} + > + {children} +
+ ) +}) diff --git a/packages/hightide-web/src/components/data-input/Checkbox/index.ts b/packages/hightide-web/src/components/data-input/Checkbox/index.ts new file mode 100644 index 00000000..dceb4a9b --- /dev/null +++ b/packages/hightide-web/src/components/data-input/Checkbox/index.ts @@ -0,0 +1,7 @@ +export { Checkbox, type CheckboxProps } from './Checkbox' +export { CheckboxContext, useCheckboxContext } from './CheckboxContext' +export { CheckboxStateManager, type CheckboxStateManagerProps } from './CheckboxStateManager' +export { CheckboxTrigger, type CheckboxTriggerProps, type CheckboxSize } from './CheckboxTrigger' +export { CheckboxIcon, type CheckboxIconProps } from './CheckboxIcon' +export type { CheckboxState, CheckboxEvent } from './CheckboxState' +export type { CheckboxContextValue } from './CheckboxContext' diff --git a/packages/hightide-web/src/components/data-input/Combobox/Combobox.tsx b/packages/hightide-web/src/components/data-input/Combobox/Combobox.tsx new file mode 100644 index 00000000..76f07d4e --- /dev/null +++ b/packages/hightide-web/src/components/data-input/Combobox/Combobox.tsx @@ -0,0 +1,18 @@ +import { ComboboxComponent } from './ComboboxComponent' +import { ComboboxContext } from './ComboboxContext' +import { ComboboxInput } from './ComboboxInput' +import { ComboboxList } from './ComboboxList' +import { ComboboxOption } from './ComboboxOption' +import { ComboboxRoot } from './ComboboxRoot' + +const Combobox = Object.assign(ComboboxComponent, { + Root: ComboboxRoot, + Input: ComboboxInput, + List: ComboboxList, + Option: ComboboxOption, + Context: ComboboxContext, + Provider: ComboboxContext.Provider, + Consumer: ComboboxContext.Consumer, +}) + +export { Combobox } diff --git a/packages/hightide-web/src/components/data-input/Combobox/ComboboxComponent.tsx b/packages/hightide-web/src/components/data-input/Combobox/ComboboxComponent.tsx new file mode 100644 index 00000000..6602cc35 --- /dev/null +++ b/packages/hightide-web/src/components/data-input/Combobox/ComboboxComponent.tsx @@ -0,0 +1,49 @@ +import type React from 'react' +import type { ForwardedRef, ReactNode } from 'react' +import { forwardRef } from 'react' +import type { ComboboxInputProps } from './ComboboxInput' +import { ComboboxInput } from './ComboboxInput' +import type { ComboboxListProps } from './ComboboxList' +import { ComboboxList } from './ComboboxList' +import type { ComboboxRootProps } from './ComboboxRoot' +import { ComboboxRoot } from './ComboboxRoot' + +export type ComboboxProps = Omit, 'children'> & { + children?: ReactNode, + placeholder?: ComboboxInputProps['placeholder'], + id?: string, + inputProps?: ComboboxInputProps, + listProps?: Omit, +} + +type ComboboxComponentType = ( + props: ComboboxProps & { + ref?: React.ForwardedRef, + } +) => React.ReactElement + +const ComboboxComponentImpl = forwardRef>(function ComboboxComponent( + { + children, + placeholder, + id, + inputProps, + listProps, + ...props + }: ComboboxProps, + ref: ForwardedRef +) { + return ( + {...props}> + + {children} + + ) +}) + +export const ComboboxComponent = ComboboxComponentImpl as ComboboxComponentType diff --git a/packages/hightide-web/src/components/user-interaction/Combobox/ComboboxContext.tsx b/packages/hightide-web/src/components/data-input/Combobox/ComboboxContext.tsx similarity index 87% rename from packages/hightide-web/src/components/user-interaction/Combobox/ComboboxContext.tsx rename to packages/hightide-web/src/components/data-input/Combobox/ComboboxContext.tsx index 064cc0ee..ae65f102 100644 --- a/packages/hightide-web/src/components/user-interaction/Combobox/ComboboxContext.tsx +++ b/packages/hightide-web/src/components/data-input/Combobox/ComboboxContext.tsx @@ -1,5 +1,6 @@ import type { Dispatch, ReactNode, RefObject, SetStateAction } from 'react' import { createContext, useContext } from 'react' +import type { InputComponentInterface } from '../input/TextInput' export interface ComboboxOptionType { id: string, @@ -50,7 +51,8 @@ export interface ComboboxContextConfig { setIds: Dispatch>, } -export interface ComboboxContextType extends ComboboxContextInternalState, ComboboxContextComputedState, ComboboxContextActions { +export interface ComboboxContextType extends ComboboxContextInternalState, ComboboxContextComputedState, ComboboxContextActions, Required, 'invalid' | 'disabled' | 'readOnly' | 'required'>> { + value?: T, config: ComboboxContextConfig, layout: ComboboxContextLayout, search: ComboboxContextSearch, @@ -61,7 +63,7 @@ export const ComboboxContext = createContext | null export function useComboboxContext(): ComboboxContextType { const ctx = useContext(ComboboxContext) if (ctx == null) { - throw new Error('useComboboxContext must be used within ComboboxRoot') + throw new Error('Combobox components must be used within a Combobox.Root') } return ctx as ComboboxContextType } diff --git a/packages/hightide-web/src/components/user-interaction/Combobox/ComboboxInput.tsx b/packages/hightide-web/src/components/data-input/Combobox/ComboboxInput.tsx similarity index 50% rename from packages/hightide-web/src/components/user-interaction/Combobox/ComboboxInput.tsx rename to packages/hightide-web/src/components/data-input/Combobox/ComboboxInput.tsx index 08fdbc0c..f3762e0c 100644 --- a/packages/hightide-web/src/components/user-interaction/Combobox/ComboboxInput.tsx +++ b/packages/hightide-web/src/components/data-input/Combobox/ComboboxInput.tsx @@ -1,9 +1,12 @@ -import { type ComponentProps, forwardRef, useCallback } from 'react' -import { Input } from '../input/Input' +import type { InputHTMLAttributes, KeyboardEvent } from 'react' +import { forwardRef, useCallback } from 'react' import { useHightideTranslation } from '@helpwave/hightide-utils/context/translation' + +import { TextInput } from '../input/TextInput' import { useComboboxContext } from './ComboboxContext' +import clsx from 'clsx' -export type ComboboxInputProps = Omit, 'value'> +export type ComboboxInputProps = Omit, 'value'> export const ComboboxInput = forwardRef( function ComboboxInput(props, ref) { @@ -11,9 +14,19 @@ export const ComboboxInput = forwardRef( const context = useComboboxContext() const { highlightNext, highlightPrevious, highlightFirst, highlightLast, highlightedId, selectOption } = context + const { + disabled, + readOnly, + required, + onKeyDown, + placeholder, + className, + ...inputProps + } = props + const handleKeyDown = useCallback( - (event: React.KeyboardEvent) => { - props.onKeyDown?.(event) + (event: KeyboardEvent) => { + onKeyDown?.(event) switch (event.key) { case 'ArrowDown': highlightNext() @@ -41,22 +54,29 @@ export const ComboboxInput = forwardRef( break } }, - [props, highlightedId, selectOption, highlightNext, highlightPrevious, highlightFirst, highlightLast] + [onKeyDown, highlightedId, selectOption, highlightNext, highlightPrevious, highlightFirst, highlightLast] ) return ( - 0} - aria-controls={context.config.ids.listbox} - aria-activedescendant={context.highlightedId ?? undefined} - aria-autocomplete="list" + isInvalid={context.invalid} + isDisabled={disabled ?? context.disabled} + isReadOnly={readOnly ?? context.readOnly} + isRequired={required ?? context.required} + inputProps={{ + ...inputProps, + 'onKeyDown': handleKeyDown, + 'placeholder': placeholder ?? translation('search'), + 'role': 'combobox', + 'aria-expanded': context.visibleOptionIds.length > 0, + 'aria-controls': context.config.ids.listbox, + 'aria-activedescendant': context.highlightedId ?? undefined, + 'aria-autocomplete': 'list', + 'className': clsx('combobox-input', className), + }} /> ) } diff --git a/packages/hightide-web/src/components/user-interaction/Combobox/ComboboxList.tsx b/packages/hightide-web/src/components/data-input/Combobox/ComboboxList.tsx similarity index 83% rename from packages/hightide-web/src/components/user-interaction/Combobox/ComboboxList.tsx rename to packages/hightide-web/src/components/data-input/Combobox/ComboboxList.tsx index 1c415b46..eff98650 100644 --- a/packages/hightide-web/src/components/user-interaction/Combobox/ComboboxList.tsx +++ b/packages/hightide-web/src/components/data-input/Combobox/ComboboxList.tsx @@ -4,10 +4,12 @@ import clsx from 'clsx' import { useComboboxContext } from './ComboboxContext' import { useHightideTranslation } from '@helpwave/hightide-utils/context/translation' -export type ComboboxListProps = HTMLAttributes +export type ComboboxListProps = HTMLAttributes & { + statusProps?: HTMLAttributes, +} export const ComboboxList = forwardRef( - function ComboboxList({ children, ...props }, ref) { + function ComboboxList({ children, statusProps, ...props }, ref) { const translation = useHightideTranslation() const context = useComboboxContext() const { layout } = context @@ -34,17 +36,16 @@ export const ComboboxList = forwardRef( role="listbox" aria-label={translation('filterOptions')} tabIndex={-1} - data-name="combobox-list" - > + className={clsx('combobox-list', props.className)}> {children}
  • 0 })} + className={clsx('combobox-list-status', { 'sr-only': count > 0 }, statusProps?.className)} > {translation('nResultsFound', { count })}
  • diff --git a/packages/hightide-web/src/components/user-interaction/Combobox/ComboboxOption.tsx b/packages/hightide-web/src/components/data-input/Combobox/ComboboxOption.tsx similarity index 73% rename from packages/hightide-web/src/components/user-interaction/Combobox/ComboboxOption.tsx rename to packages/hightide-web/src/components/data-input/Combobox/ComboboxOption.tsx index fa5bb8d2..60ddadc0 100644 --- a/packages/hightide-web/src/components/user-interaction/Combobox/ComboboxOption.tsx +++ b/packages/hightide-web/src/components/data-input/Combobox/ComboboxOption.tsx @@ -1,7 +1,8 @@ -import type { HTMLAttributes, ReactNode, RefObject } from 'react' -import { forwardRef, useEffect, useId, useRef } from 'react' +import type { ForwardedRef, HTMLAttributes, ReactNode, RefObject } from 'react' +import { forwardRef, useEffect, useId, useMemo, useRef } from 'react' import clsx from 'clsx' import { useComboboxContext } from './ComboboxContext' +import { ColoringUtils } from '../../interaction' export interface ComboboxOptionProps extends HTMLAttributes { value: T, @@ -13,11 +14,11 @@ export const ComboboxOption = forwardRef, ref: ForwardedRef) { const context = useComboboxContext() const { registerOption } = context const itemRef = useRef(null) @@ -26,6 +27,14 @@ export const ComboboxOption = forwardRef + disabledOverwrite ?? context.disabled, + [context.disabled, disabledOverwrite]) + + const interactable = useMemo(() => + !disabled && !context.readOnly, + [context.readOnly, disabled]) + useEffect(() => { return registerOption({ id: optionId, @@ -62,24 +71,23 @@ export const ComboboxOption = forwardRef { - if (!disabled) { + if (!interactable) { context.selectOption(optionId) restProps.onClick?.(event) } }} onMouseEnter={(event) => { - if (!disabled) { + if (!interactable) { context.highlightItem(optionId) restProps.onMouseEnter?.(event) } - }} - > + }}> {resolvedDisplay} ) diff --git a/packages/hightide-web/src/components/user-interaction/Combobox/ComboboxRoot.tsx b/packages/hightide-web/src/components/data-input/Combobox/ComboboxRoot.tsx similarity index 71% rename from packages/hightide-web/src/components/user-interaction/Combobox/ComboboxRoot.tsx rename to packages/hightide-web/src/components/data-input/Combobox/ComboboxRoot.tsx index 32bfc855..e156762e 100644 --- a/packages/hightide-web/src/components/user-interaction/Combobox/ComboboxRoot.tsx +++ b/packages/hightide-web/src/components/data-input/Combobox/ComboboxRoot.tsx @@ -1,18 +1,29 @@ import type { ReactNode, RefObject } from 'react' import { useCallback, useId, useMemo, useState } from 'react' +import { useControlledState, useStableEvent } from '@helpwave/hightide-utils/hooks' + +import type { InputComponentInterface } from '../input/TextInput' +import { DOMUtils } from '../../../utils/dom' import { ComboboxContext } from './ComboboxContext' import type { ComboboxContextConfig, ComboboxContextIds, ComboboxContextLayout, ComboboxContextType, ComboboxOptionType } from './ComboboxContext' import type { UseComboboxOptions } from './useCombobox' import { useCombobox } from './useCombobox' -import { DOMUtils } from '../../../utils/dom' -export interface ComboboxRootProps extends Omit { +export interface ComboboxRootProps extends InputComponentInterface, Omit { children: ReactNode, onItemClick?: (value: T) => void, } export function ComboboxRoot({ children, + value, + initialValue, + onValueUpdate: onValueChange, + onValueCommit: onEditComplete, + invalid = false, + disabled = false, + readOnly = false, + required = false, onItemClick, ...hookProps }: ComboboxRootProps) { @@ -23,6 +34,18 @@ export function ComboboxRoot({ trigger: `combobox-${generatedId}`, listbox: `combobox-${generatedId}-listbox`, }) + const onValueChangeStable = useStableEvent(onValueChange) + const onEditCompleteStable = useStableEvent(onEditComplete) + const onItemClickStable = useStableEvent(onItemClick) + const [selectedValue, setSelectedValue] = useControlledState({ + value, + onValueChange: (next) => { + if (next !== undefined) { + onValueChangeStable(next) + } + }, + defaultValue: initialValue, + }) const registerOption = useCallback( (option: ComboboxOptionType) => { @@ -65,10 +88,19 @@ export function ComboboxRoot({ const selectOption = useCallback( (id: string) => { + if (disabled || readOnly) { + return + } const option = idToOptionMap[id] - if (option) onItemClick?.(option.value as T) + if (!option || option.disabled) { + return + } + setSelectedValue(option.value) + onEditCompleteStable(option.value) + onItemClickStable(option.value) + state.setSearchQuery(option.label ?? '') }, - [idToOptionMap, onItemClick] + [disabled, readOnly, idToOptionMap, setSelectedValue, onEditCompleteStable, onItemClickStable, state] ) const config: ComboboxContextConfig = useMemo( @@ -94,6 +126,11 @@ export function ComboboxRoot({ const contextValue = useMemo( () => ({ + value: selectedValue, + invalid, + disabled, + readOnly, + required, highlightedId: state.highlightedId, options, visibleOptionIds: state.visibleOptionIds, @@ -110,6 +147,11 @@ export function ComboboxRoot({ search, }), [ + selectedValue, + invalid, + disabled, + readOnly, + required, state.highlightedId, state.visibleOptionIds, state.highlightFirst, diff --git a/packages/hightide-web/src/components/data-input/Combobox/index.ts b/packages/hightide-web/src/components/data-input/Combobox/index.ts new file mode 100644 index 00000000..b2507abb --- /dev/null +++ b/packages/hightide-web/src/components/data-input/Combobox/index.ts @@ -0,0 +1,2 @@ +export { Combobox } from './Combobox' +export type { ComboboxProps } from './ComboboxComponent' diff --git a/packages/hightide-web/src/components/user-interaction/Combobox/useCombobox.ts b/packages/hightide-web/src/components/data-input/Combobox/useCombobox.ts similarity index 100% rename from packages/hightide-web/src/components/user-interaction/Combobox/useCombobox.ts rename to packages/hightide-web/src/components/data-input/Combobox/useCombobox.ts diff --git a/packages/hightide-web/src/components/user-interaction/FileInput/FileInput.tsx b/packages/hightide-web/src/components/data-input/FileInput/FileInput.tsx similarity index 100% rename from packages/hightide-web/src/components/user-interaction/FileInput/FileInput.tsx rename to packages/hightide-web/src/components/data-input/FileInput/FileInput.tsx diff --git a/packages/hightide-web/src/components/user-interaction/FileInput/FileInputComponent.tsx b/packages/hightide-web/src/components/data-input/FileInput/FileInputComponent.tsx similarity index 100% rename from packages/hightide-web/src/components/user-interaction/FileInput/FileInputComponent.tsx rename to packages/hightide-web/src/components/data-input/FileInput/FileInputComponent.tsx diff --git a/packages/hightide-web/src/components/user-interaction/FileInput/FileInputContext.tsx b/packages/hightide-web/src/components/data-input/FileInput/FileInputContext.tsx similarity index 84% rename from packages/hightide-web/src/components/user-interaction/FileInput/FileInputContext.tsx rename to packages/hightide-web/src/components/data-input/FileInput/FileInputContext.tsx index 57db835d..23ba757e 100644 --- a/packages/hightide-web/src/components/user-interaction/FileInput/FileInputContext.tsx +++ b/packages/hightide-web/src/components/data-input/FileInput/FileInputContext.tsx @@ -1,11 +1,11 @@ import type { RefObject } from 'react' import { createContext, useContext } from 'react' -import type { FormFieldInteractionStates } from '../../form/FieldLayout' +import type { InputComponentInterface } from '../input/TextInput' import type { FileInputItem } from './fileInputItem' export type FileInputPickFiles = () => Promise -export type FileInputContextType = Partial & { +export type FileInputContextType = Pick, 'invalid' | 'disabled' | 'readOnly' | 'required'> & { files: readonly FileInputItem[], isOpen: boolean, isDragging: boolean, diff --git a/packages/hightide-web/src/components/user-interaction/FileInput/FileInputMenu.tsx b/packages/hightide-web/src/components/data-input/FileInput/FileInputMenu.tsx similarity index 50% rename from packages/hightide-web/src/components/user-interaction/FileInput/FileInputMenu.tsx rename to packages/hightide-web/src/components/data-input/FileInput/FileInputMenu.tsx index fb0852dd..89404620 100644 --- a/packages/hightide-web/src/components/user-interaction/FileInput/FileInputMenu.tsx +++ b/packages/hightide-web/src/components/data-input/FileInput/FileInputMenu.tsx @@ -2,11 +2,13 @@ import type { ComponentPropsWithoutRef, ReactNode } from 'react' import { FileText, Plus, X } from 'lucide-react' import clsx from 'clsx' import { useHightideTranslation } from '@helpwave/hightide-utils/context/translation' -import { Dialog } from '../../layout/dialog/Dialog' -import { Button } from '../Button' -import { IconButton } from '../IconButton' +import { Modal } from '../../layout/Modal/Modal' +import { Button } from '../../interaction/Button' +import { Icon } from '../../visualization/Icon' +import { IconButton } from '../../interaction/IconButton' import { useFileInputContext } from './FileInputContext' import { + createFileInputItemsFromFileList, formatFileInputAccept, isFileDataTransfer @@ -27,75 +29,76 @@ export const FileInputMenu = ({ const allowedFileTypes = formatFileInputAccept(context.accept) return ( - 1} onClose={() => context.setIsOpen(false)} titleElement={translation('selectFiles')} description={translation('dropFilesHere')} - className={clsx('file-input-menu', className)} - data-file-drag={context.isDragging ? '' : undefined} - data-drag-over={context.isDragOver ? '' : undefined} - onDragEnter={(event) => { - event.preventDefault() - if (canEdit && context.canAddFiles && isFileDataTransfer(event.dataTransfer)) { - context.setIsDragOver(true) - } - props.onDragEnter?.(event) - }} - onDragOver={(event) => { - event.preventDefault() - props.onDragOver?.(event) - }} - onDragLeave={(event) => { - event.preventDefault() - const nextTarget = event.relatedTarget - if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) { + panelProps={{ + ...props, + 'className': clsx('file-input-menu', className), + 'data-file-drag': context.isDragging ? '' : undefined, + 'data-drag-over': context.isDragOver ? '' : undefined, + 'onDragEnter': (event) => { + event.preventDefault() + if (canEdit && context.canAddFiles && isFileDataTransfer(event.dataTransfer)) { + context.setIsDragOver(true) + } + props.onDragEnter?.(event) + }, + 'onDragOver': (event) => { + event.preventDefault() + props.onDragOver?.(event) + }, + 'onDragLeave': (event) => { + event.preventDefault() + const nextTarget = event.relatedTarget + if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) { + props.onDragLeave?.(event) + return + } + context.setIsDragOver(false) props.onDragLeave?.(event) - return - } - context.setIsDragOver(false) - props.onDragLeave?.(event) - }} - onDrop={(event) => { - event.preventDefault() - context.setIsDragOver(false) - context.setIsDragging(false) - if (canEdit && context.canAddFiles) { - context.addFiles(createFileInputItemsFromFileList(event.dataTransfer.files)) - } - props.onDrop?.(event) + }, + 'onDrop': (event) => { + event.preventDefault() + context.setIsDragOver(false) + context.setIsDragging(false) + if (canEdit && context.canAddFiles) { + context.addFiles(createFileInputItemsFromFileList(event.dataTransfer.files)) + } + props.onDrop?.(event) + }, }} > {(context.maxFiles != null || allowedFileTypes != null) && ( -
    +
    {context.maxFiles != null && ( - + {translation('maximumNumberOfFiles', { count: context.maxFiles })} )} {allowedFileTypes != null && ( - + {translation('allowedFileTypes', { types: allowedFileTypes })} )}
    )} -
    +
    {context.files.map((file) => ( -
    - - {file.name} +
    + + {file.name} {canEdit && ( context.removeFile(file.id)} - > - - + icon={X} + /> )}
    ))} @@ -103,15 +106,15 @@ export const FileInputMenu = ({ {canEdit && ( )} {children} -
    + ) } diff --git a/packages/hightide-web/src/components/user-interaction/FileInput/FileInputRoot.tsx b/packages/hightide-web/src/components/data-input/FileInput/FileInputRoot.tsx similarity index 89% rename from packages/hightide-web/src/components/user-interaction/FileInput/FileInputRoot.tsx rename to packages/hightide-web/src/components/data-input/FileInput/FileInputRoot.tsx index c0486960..e60a3671 100644 --- a/packages/hightide-web/src/components/user-interaction/FileInput/FileInputRoot.tsx +++ b/packages/hightide-web/src/components/data-input/FileInput/FileInputRoot.tsx @@ -2,10 +2,9 @@ import type { ReactNode } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useControlledState, - useEventCallbackStabilizer + useStableEvent } from '@helpwave/hightide-utils/hooks' -import type { FormFieldDataHandling } from '../../form/FormField' -import type { FormFieldInteractionStates } from '../../form/FieldLayout' +import type { InputComponentInterface } from '../input/TextInput' import { SafeGlobals } from '../../../utils/safeGlobals' import { FileInputContext, type FileInputContextType, type FileInputPickFiles } from './FileInputContext' import { @@ -17,9 +16,8 @@ import { type FileInputItem } from './fileInputItem' -export interface FileInputRootProps extends Omit>, 'value'>, Partial { +export interface FileInputRootProps extends Omit, 'value'> { value?: readonly FileInputItem[], - initialValue?: readonly FileInputItem[], initialIsOpen?: boolean, onClose?: () => void, onIsOpenChange?: (isOpen: boolean) => void, @@ -32,8 +30,8 @@ export interface FileInputRootProps extends Omit { setFiles(next) diff --git a/packages/hightide-web/src/components/user-interaction/FileInput/FileInputTrigger.tsx b/packages/hightide-web/src/components/data-input/FileInput/FileInputTrigger.tsx similarity index 84% rename from packages/hightide-web/src/components/user-interaction/FileInput/FileInputTrigger.tsx rename to packages/hightide-web/src/components/data-input/FileInput/FileInputTrigger.tsx index ab255c6c..08af4b94 100644 --- a/packages/hightide-web/src/components/user-interaction/FileInput/FileInputTrigger.tsx +++ b/packages/hightide-web/src/components/data-input/FileInput/FileInputTrigger.tsx @@ -1,7 +1,9 @@ +import clsx from 'clsx' import type React from 'react' import type { ComponentPropsWithoutRef, ForwardedRef, ReactNode } from 'react' import { forwardRef } from 'react' import { FileText, Pencil, Plus } from 'lucide-react' +import { Icon } from '../../visualization/Icon' import { useHightideTranslation } from '@helpwave/hightide-utils/context/translation' import { ReactUtils } from '@helpwave/hightide-utils/utils' import { useFileInputContext } from './FileInputContext' @@ -55,7 +57,7 @@ const FileInputTriggerImpl = forwardRef(f event.preventDefault() } }} - data-name="file-input-trigger" + className={clsx('file-input-trigger input-element', props.className)} data-value={hasFiles ? '' : undefined} data-disabled={disabled ? '' : undefined} data-readonly={readOnly ? '' : undefined} @@ -66,30 +68,25 @@ const FileInputTriggerImpl = forwardRef(f aria-disabled={disabled} aria-readonly={readOnly} aria-haspopup={(context.maxFiles ?? 1) > 1 ? 'dialog' : undefined} - aria-expanded={(context.maxFiles ?? 1) > 1 ? context.isOpen : undefined} - > -
    + aria-expanded={(context.maxFiles ?? 1) > 1 ? context.isOpen : undefined}> +
    {hasFiles ? ( <> {visible.map((file) => ( -
    - - {file.name} +
    + + {file.name}
    ))} {hiddenCount > 0 && ( - + {translation('nMoreFiles', { count: hiddenCount })} )} ) : resolvedPlaceholder}
    - {hasFiles ? ( - - ) : ( - - )} +
    ) }) diff --git a/packages/hightide-web/src/components/user-interaction/FileInput/fileInputItem.ts b/packages/hightide-web/src/components/data-input/FileInput/fileInputItem.ts similarity index 100% rename from packages/hightide-web/src/components/user-interaction/FileInput/fileInputItem.ts rename to packages/hightide-web/src/components/data-input/FileInput/fileInputItem.ts diff --git a/packages/hightide-web/src/components/user-interaction/FileInput/index.ts b/packages/hightide-web/src/components/data-input/FileInput/index.ts similarity index 100% rename from packages/hightide-web/src/components/user-interaction/FileInput/index.ts rename to packages/hightide-web/src/components/data-input/FileInput/index.ts diff --git a/packages/hightide-web/src/components/user-interaction/LabelledCheckbox.tsx b/packages/hightide-web/src/components/data-input/LabelledCheckbox.tsx similarity index 82% rename from packages/hightide-web/src/components/user-interaction/LabelledCheckbox.tsx rename to packages/hightide-web/src/components/data-input/LabelledCheckbox.tsx index a1f3b0aa..93598960 100644 --- a/packages/hightide-web/src/components/user-interaction/LabelledCheckbox.tsx +++ b/packages/hightide-web/src/components/data-input/LabelledCheckbox.tsx @@ -3,19 +3,16 @@ import type { HTMLAttributes, ReactNode } from 'react' import { forwardRef, useCallback, useId } from 'react' import { Checkbox, type CheckboxProps } from './Checkbox' import { PropsUtil } from '../../utils/propsUtil' -import type { FormFieldDataHandling } from '../form/FormField' -import type { FormFieldInteractionStates } from '../form/FieldLayout' +import type { InputComponentInterface } from './input/TextInput' import { useControlledState } from '@helpwave/hightide-utils/hooks' -import { useEventCallbackStabilizer } from '@helpwave/hightide-utils/hooks' +import { useStableEvent } from '@helpwave/hightide-utils/hooks' type LabelledCheckboxCheckPosition = 'left' | 'right' export type LabelledCheckboxProps = Omit, 'children'> - & Partial - & Partial> + & InputComponentInterface & Pick & { - initialValue?: boolean, label: ReactNode, checkPosition?: LabelledCheckboxCheckPosition, checkboxClassName?: string, @@ -28,8 +25,8 @@ export type LabelledCheckboxProps = Omit, 'childr export const LabelledCheckbox = forwardRef(function LabelledCheckbox({ value: controlledValue, initialValue = false, - onValueChange, - onEditComplete, + onValueUpdate: onValueChange, + onValueCommit: onEditComplete, indeterminate, required = false, invalid = false, @@ -50,8 +47,8 @@ export const LabelledCheckbox = forwardRef { onValueChangeStable(value) onEditCompleteStable(value) @@ -73,15 +70,20 @@ export const LabelledCheckbox = forwardRef event.stopPropagation(), + onKeyDown: (event) => event.stopPropagation(), + }} /> ) diff --git a/packages/hightide-web/src/components/user-interaction/MultiSelect/MultiSelect.tsx b/packages/hightide-web/src/components/data-input/MultiSelect/MultiSelect.tsx similarity index 100% rename from packages/hightide-web/src/components/user-interaction/MultiSelect/MultiSelect.tsx rename to packages/hightide-web/src/components/data-input/MultiSelect/MultiSelect.tsx diff --git a/packages/hightide-web/src/components/user-interaction/MultiSelect/MultiSelectChipDisplayTrigger.tsx b/packages/hightide-web/src/components/data-input/MultiSelect/MultiSelectChipDisplayTrigger.tsx similarity index 86% rename from packages/hightide-web/src/components/user-interaction/MultiSelect/MultiSelectChipDisplayTrigger.tsx rename to packages/hightide-web/src/components/data-input/MultiSelect/MultiSelectChipDisplayTrigger.tsx index 0ad6bbe4..28338aea 100644 --- a/packages/hightide-web/src/components/user-interaction/MultiSelect/MultiSelectChipDisplayTrigger.tsx +++ b/packages/hightide-web/src/components/data-input/MultiSelect/MultiSelectChipDisplayTrigger.tsx @@ -1,11 +1,12 @@ import type { ForwardedRef, HTMLAttributes, ReactNode } from 'react' import { forwardRef, useEffect, useRef } from 'react' import { useMultiSelectContext } from './MultiSelectContext' -import { IconButton } from '../IconButton' +import { IconButton } from '../../interaction/IconButton' import { useHightideTranslation } from '@helpwave/hightide-utils/context/translation' import { XIcon, Plus } from 'lucide-react' import { ReactUtils } from '@helpwave/hightide-utils/utils' import clsx from 'clsx' +import { ColoringUtils } from '../../interaction' export type MultiSelectChipDisplayTriggerProps = HTMLAttributes & { disabled?: boolean, @@ -45,7 +46,7 @@ export const MultiSelectChipDisplayTrigger = forwardRef<
    0 ? '' : undefined} data-disabled={disabled ? '' : undefined} data-readonly={readOnly ? '' : undefined} @@ -56,14 +57,13 @@ export const MultiSelectChipDisplayTrigger = forwardRef< >
    { - console.log('clicked') if (!hasInteractions) return context.toggleIsOpen() }} className="multi-select-chip-display-button" /> {selectedOptions.map((opt) => ( -
    +
    {opt.display} - - + variant="foreground" + className="multi-select-chip-display-chip-icon-button" + icon={XIcon} + />
    ))} - - + icon={Plus} + />
    ) }) diff --git a/packages/hightide-web/src/components/user-interaction/MultiSelect/MultiSelectComponent.tsx b/packages/hightide-web/src/components/data-input/MultiSelect/MultiSelectComponent.tsx similarity index 98% rename from packages/hightide-web/src/components/user-interaction/MultiSelect/MultiSelectComponent.tsx rename to packages/hightide-web/src/components/data-input/MultiSelect/MultiSelectComponent.tsx index 997ee0bf..a64b8872 100644 --- a/packages/hightide-web/src/components/user-interaction/MultiSelect/MultiSelectComponent.tsx +++ b/packages/hightide-web/src/components/data-input/MultiSelect/MultiSelectComponent.tsx @@ -20,7 +20,7 @@ type MultiSelectComponentType = ( props: MultiSelectProps & { ref?: React.ForwardedRef, } -) => React.ReactElement | null +) => React.ReactElement const MultiSelectComponentImpl = forwardRef< HTMLDivElement, diff --git a/packages/hightide-web/src/components/user-interaction/MultiSelect/MultiSelectContent.tsx b/packages/hightide-web/src/components/data-input/MultiSelect/MultiSelectContent.tsx similarity index 68% rename from packages/hightide-web/src/components/user-interaction/MultiSelect/MultiSelectContent.tsx rename to packages/hightide-web/src/components/data-input/MultiSelect/MultiSelectContent.tsx index 4dcd6ee5..b14ffea7 100644 --- a/packages/hightide-web/src/components/user-interaction/MultiSelect/MultiSelectContent.tsx +++ b/packages/hightide-web/src/components/data-input/MultiSelect/MultiSelectContent.tsx @@ -1,28 +1,26 @@ -import type { ComponentProps, ForwardedRef } from 'react' +import type { ForwardedRef } from 'react' import { forwardRef, useCallback, useEffect, useRef } from 'react' import { useMultiSelectContext } from './MultiSelectContext' import clsx from 'clsx' import { useHightideTranslation } from '@helpwave/hightide-utils/context/translation' -import { PopUp, type PopUpProps } from '../../layout/popup/PopUp' -import { Input } from '../input/Input' +import { PopUp, type PopUpProps } from '../../layout/PopUp/PopUp' +import { TextInput, type TextInputProps } from '../input/TextInput' import { Visibility } from '../../layout/Visibility' import { ReactUtils } from '@helpwave/hightide-utils/utils' export interface MultiSelectContentProps extends PopUpProps { - showSearch?: boolean, - searchInputProps?: Omit, 'value' | 'onValueChange'>, + searchInputProps?: Omit, } export const MultiSelectContent = forwardRef< HTMLUListElement, MultiSelectContentProps >(function MultiSelectContent( - { id, options, showSearch: showSearchOverride, searchInputProps, ...props } : MultiSelectContentProps, + { id, options, searchInputProps, ...props }: MultiSelectContentProps, ref: ForwardedRef ) { const translation = useHightideTranslation() const innerRef = useRef(null) - const searchInputRef = useRef(null) const context = useMultiSelectContext() const { config, highlightNext, highlightPrevious, highlightFirst, highlightLast, highlightedId, handleTypeaheadKey, toggleSelection } = context @@ -32,7 +30,7 @@ export const MultiSelectContent = forwardRef< if (id) setIds((prev) => ({ ...prev, content: id })) }, [id, setIds]) - const showSearch = showSearchOverride ?? context.search.hasSearch + const showSearch = context.search.hasSearch const listboxAriaLabel = showSearch ? translation('searchResults') : undefined const keyHandler = useCallback( @@ -95,23 +93,32 @@ export const MultiSelectContent = forwardRef< className={clsx('multi-select-content', props.className)} > {showSearch && ( - { + searchInputProps?.inputProps?.onKeyDown?.(event) + keyHandler(event) + }, + 'placeholder': searchInputProps?.inputProps?.placeholder ?? translation('filterOptions'), + 'role': 'combobox', + 'aria-autocomplete': 'list', + 'aria-expanded': context.isOpen, + 'aria-controls': context.config.ids.listbox, + 'aria-activedescendant': context.highlightedId ?? undefined, + 'aria-label': searchInputProps?.inputProps?.['aria-label'] ?? translation('filterOptions'), + 'className': clsx('mx-2 mt-2 shrink-0', searchInputProps?.inputProps?.className), + }} /> )}
      {props.children} @@ -134,8 +140,7 @@ export const MultiSelectContent = forwardRef< aria-disabled={true} aria-live="polite" aria-atomic={true} - data-name="multi-select-list-status" - className={clsx({ + className={clsx('multi-select-list-status', { 'sr-only': context.visibleOptionIds.length > 0, })} > diff --git a/packages/hightide-web/src/components/user-interaction/MultiSelect/MultiSelectContext.tsx b/packages/hightide-web/src/components/data-input/MultiSelect/MultiSelectContext.tsx similarity index 92% rename from packages/hightide-web/src/components/user-interaction/MultiSelect/MultiSelectContext.tsx rename to packages/hightide-web/src/components/data-input/MultiSelect/MultiSelectContext.tsx index 2693c32d..af4bf14b 100644 --- a/packages/hightide-web/src/components/user-interaction/MultiSelect/MultiSelectContext.tsx +++ b/packages/hightide-web/src/components/data-input/MultiSelect/MultiSelectContext.tsx @@ -1,6 +1,6 @@ import type { Dispatch, ReactNode, RefObject, SetStateAction } from 'react' import { createContext, useContext } from 'react' -import type { FormFieldInteractionStates } from '../../form/FieldLayout' +import type { InputComponentInterface } from '../input/TextInput' import type { UseMultiSelectFirstHighlightBehavior } from './useMultiSelect' export type MultiSelectOptionIdentity = { @@ -23,7 +23,7 @@ export interface MultiSelectContextIds { searchInput: string, } -export interface MultiSelectContextState extends FormFieldInteractionStates { +export interface MultiSelectContextState extends Required, 'invalid' | 'disabled' | 'readOnly' | 'required'>> { value: T[], options: ReadonlyArray>, selectedIds: string[], diff --git a/packages/hightide-web/src/components/user-interaction/MultiSelect/MultiSelectOption.tsx b/packages/hightide-web/src/components/data-input/MultiSelect/MultiSelectOption.tsx similarity index 87% rename from packages/hightide-web/src/components/user-interaction/MultiSelect/MultiSelectOption.tsx rename to packages/hightide-web/src/components/data-input/MultiSelect/MultiSelectOption.tsx index 99bde54d..ddecd65e 100644 --- a/packages/hightide-web/src/components/user-interaction/MultiSelect/MultiSelectOption.tsx +++ b/packages/hightide-web/src/components/data-input/MultiSelect/MultiSelectOption.tsx @@ -1,5 +1,7 @@ import clsx from 'clsx' import { CheckIcon } from 'lucide-react' +import { Icon } from '../../visualization/Icon' +import { ColoringUtils } from '../../../utils/coloring' import type React from 'react' import type { ForwardedRef, HTMLAttributes, RefObject } from 'react' import { createContext, forwardRef, useContext, useEffect, useMemo, useRef } from 'react' @@ -42,7 +44,7 @@ type MultiSelectOptionComponent = ( props: MultiSelectOptionProps & { ref?: React.ForwardedRef, } -) => React.ReactElement | null +) => React.ReactElement const toIdentity = (value: T, valueId: string | undefined): MultiSelectOptionIdentity => { if (valueId === undefined) { @@ -104,7 +106,12 @@ const MultiSelectOptionImpl = forwardRef< aria-selected={isSelected} aria-hidden={!isVisible} - data-name="multi-select-list-option" + className={clsx('multi-select-list-option', props.className)} + {...ColoringUtils.build({ + color: 'surface-inverse', + mode: 'interactive', + coloringStyle: 'foreground', + })} data-highlighted={isHighlighted ? '' : undefined} data-selected={isSelected ? '' : undefined} data-disabled={disabled ? '' : undefined} @@ -121,11 +128,12 @@ const MultiSelectOptionImpl = forwardRef< context.highlightItem(optionId) props.onMouseEnter?.(event) } - }} - > + }}> {iconAppearanceResolved === 'left' && ( - )} @@ -133,8 +141,10 @@ const MultiSelectOptionImpl = forwardRef< {display} {iconAppearanceResolved === 'right' && ( - )} diff --git a/packages/hightide-web/src/components/user-interaction/MultiSelect/MultiSelectRoot.tsx b/packages/hightide-web/src/components/data-input/MultiSelect/MultiSelectRoot.tsx similarity index 81% rename from packages/hightide-web/src/components/user-interaction/MultiSelect/MultiSelectRoot.tsx rename to packages/hightide-web/src/components/data-input/MultiSelect/MultiSelectRoot.tsx index 38f3a055..cb923703 100644 --- a/packages/hightide-web/src/components/user-interaction/MultiSelect/MultiSelectRoot.tsx +++ b/packages/hightide-web/src/components/data-input/MultiSelect/MultiSelectRoot.tsx @@ -1,12 +1,11 @@ import type { ReactNode, RefObject, SetStateAction } from 'react' -import { useCallback, useEffect, useId, useMemo, useState } from 'react' +import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react' import { MultiSelectContext } from './MultiSelectContext' import type { MultiSelectContextType, MultiSelectIconAppearance, MultiSelectOptionType } from './MultiSelectContext' import { useMultiSelect } from './useMultiSelect' import { DOMUtils } from '../../../utils/dom' -import type { FormFieldDataHandling } from '../../form/FormField' -import type { FormFieldInteractionStates } from '../../form/FieldLayout' -import { PopUpContext } from '../../layout/popup/PopUpContext' +import type { InputComponentInterface } from '../input/TextInput' +import { PopUpContext } from '../../layout/PopUp/PopUpContext' export interface MultiSelectIds { trigger: string, @@ -15,12 +14,11 @@ export interface MultiSelectIds { searchInput: string, } -export interface MultiSelectRootProps extends Partial>, Partial { - initialValue?: T[], +export interface MultiSelectRootProps extends InputComponentInterface { compareFunction?: (a: T, b: T) => boolean, initialIsOpen?: boolean, onClose?: () => void, - showSearch?: boolean, + searchableThreshold?: number, iconAppearance?: MultiSelectIconAppearance, children: ReactNode, } @@ -28,21 +26,23 @@ export interface MultiSelectRootProps extends Partial({ children, value, - onValueChange, - onEditComplete, + onValueUpdate: onValueChange, + onValueCommit: onEditComplete, initialValue, compareFunction, initialIsOpen = false, onClose, - showSearch = true, + searchableThreshold = 6, iconAppearance = 'right', invalid = false, disabled = false, readOnly = false, required = false, }: MultiSelectRootProps) { - const [triggerRef, setTriggerRef] = useState | null>(null) + const nullTrigger = useRef(null) + const [triggerRef, setTriggerRef] = useState>(nullTrigger) const [options, setOptions] = useState[]>([]) + const [optionSnapshots, setOptionSnapshots] = useState>>({}) const generatedId = useId() const [ids, setIds] = useState({ trigger: 'multi-select-' + generatedId, @@ -52,6 +52,7 @@ export function MultiSelectRoot({ }) const registerOption = useCallback((item: MultiSelectOptionType) => { + setOptionSnapshots((previous) => ({ ...previous, [item.value.id]: item })) setOptions((prev) => { const next = prev.filter((o) => o.value.id !== item.value.id) next.push(item) @@ -62,9 +63,9 @@ export function MultiSelectRoot({ return () => setOptions((prev) => prev.filter((o) => o.value.id !== item.value.id)) }, []) - const registerTrigger = useCallback((ref: RefObject) => { + const registerTrigger = useCallback((ref: RefObject) => { setTriggerRef(ref) - return () => setTriggerRef(null) + return () => setTriggerRef(nullTrigger) }, []) const compare = useMemo(() => compareFunction ?? Object.is, [compareFunction]) @@ -82,14 +83,14 @@ export function MultiSelectRoot({ ) const mappedValueIds = useMemo(() => { - if (value == null) return undefined + if (value === undefined) return undefined return value .map((v) => options.find((o) => compare(o.value.value, v))?.value.id) .filter((id) => id !== undefined) }, [options, value, compare]) const mappedInitialValueIds = useMemo(() => { - if (initialValue == null) return [] + if (initialValue === undefined) return [] return initialValue .map((v) => options.find((o) => compare(o.value.value, v))?.value.id) .filter((id) => id !== undefined) @@ -99,7 +100,7 @@ export function MultiSelectRoot({ (ids: string[]) => { const values = ids .map((id) => idToOptionMap[id]?.value.value) - .filter((v): v is T => v != null) + .filter((value): value is T => value !== undefined) onValueChange?.(values) }, [idToOptionMap, onValueChange] @@ -109,7 +110,7 @@ export function MultiSelectRoot({ (ids: string[]) => { const values = ids .map((id) => idToOptionMap[id]?.value.value) - .filter((v): v is T => v != null) + .filter((value): value is T => value !== undefined) onEditComplete?.(values) }, [idToOptionMap, onEditComplete] @@ -125,17 +126,19 @@ export function MultiSelectRoot({ onClose, }) const { setSearchQuery } = state + const knownOptionCount = Math.max(options.length, Object.keys(optionSnapshots).length) + const hasSearch = knownOptionCount >= searchableThreshold useEffect(() => { - if (showSearch === false) { + if (!hasSearch) { setSearchQuery('') } - }, [showSearch, setSearchQuery]) + }, [hasSearch, setSearchQuery]) const contextValue = useMemo((): MultiSelectContextType => { const valueT = state.value .map((id) => idToOptionMap[id]?.value.value) - .filter((v): v is T => v != null) + .filter((value): value is T => value !== undefined) return { invalid, disabled, @@ -168,7 +171,7 @@ export function MultiSelectRoot({ registerTrigger, }, search: { - hasSearch: showSearch, + hasSearch, searchQuery: state.searchQuery, setSearchQuery: state.setSearchQuery, }, @@ -186,7 +189,7 @@ export function MultiSelectRoot({ ids, triggerRef, registerTrigger, - showSearch, + hasSearch, ]) const setIsOpen = useCallback((updater: SetStateAction) => { diff --git a/packages/hightide-web/src/components/user-interaction/MultiSelect/MultiSelectTrigger.tsx b/packages/hightide-web/src/components/data-input/MultiSelect/MultiSelectTrigger.tsx similarity index 91% rename from packages/hightide-web/src/components/user-interaction/MultiSelect/MultiSelectTrigger.tsx rename to packages/hightide-web/src/components/data-input/MultiSelect/MultiSelectTrigger.tsx index 2d0a204e..02a29848 100644 --- a/packages/hightide-web/src/components/user-interaction/MultiSelect/MultiSelectTrigger.tsx +++ b/packages/hightide-web/src/components/data-input/MultiSelect/MultiSelectTrigger.tsx @@ -1,26 +1,26 @@ +import clsx from 'clsx' import type React from 'react' import type { ComponentPropsWithoutRef, ForwardedRef, ReactNode } from 'react' import { forwardRef, useEffect, useRef } from 'react' import { useMultiSelectContext } from './MultiSelectContext' import { useHightideTranslation } from '@helpwave/hightide-utils/context/translation' -import { ExpansionIcon } from '../../display-and-visualization/ExpansionIcon' +import { ExpansionIcon } from '../../visualization/ExpansionIcon' import { MultiSelectOptionDisplayContext } from './MultiSelectOption' import { ReactUtils } from '@helpwave/hightide-utils/utils' export interface MultiSelectTriggerProps extends ComponentPropsWithoutRef<'div'> { - 'placeholder'?: ReactNode, - 'disabled'?: boolean, - 'selectedDisplay'?: (values: T[]) => ReactNode, - 'hideExpansionIcon'?: boolean, - 'data-name'?: string, + placeholder?: ReactNode, + disabled?: boolean, + selectedDisplay?: (values: T[]) => ReactNode, + hideExpansionIcon?: boolean, } type MultiSelectTriggerComponent = ( props: MultiSelectTriggerProps & { ref?: React.ForwardedRef, } -) => React.ReactElement | null +) => React.ReactElement const MultiSelectTriggerImpl = forwardRef< HTMLDivElement, @@ -91,7 +91,7 @@ const MultiSelectTriggerImpl = forwardRef< break } }} - data-name={props['data-name'] ?? 'multi-select-button'} + className={clsx('multi-select-button input-element', props.className)} data-value={hasValue ? '' : undefined} data-disabled={disabled ? '' : undefined} data-readonly={readOnly ? '' : undefined} @@ -103,8 +103,7 @@ const MultiSelectTriggerImpl = forwardRef< aria-readonly={readOnly} aria-haspopup="dialog" aria-expanded={context.isOpen} - aria-controls={context.isOpen ? context.config.ids.content : undefined} - > + aria-controls={context.isOpen ? context.config.ids.content : undefined}> {hasValue ? selectedDisplay?.(context.value) ?? ( diff --git a/packages/hightide-web/src/components/user-interaction/MultiSelect/index.ts b/packages/hightide-web/src/components/data-input/MultiSelect/index.ts similarity index 100% rename from packages/hightide-web/src/components/user-interaction/MultiSelect/index.ts rename to packages/hightide-web/src/components/data-input/MultiSelect/index.ts diff --git a/packages/hightide-web/src/components/user-interaction/MultiSelect/useMultiSelect.ts b/packages/hightide-web/src/components/data-input/MultiSelect/useMultiSelect.ts similarity index 96% rename from packages/hightide-web/src/components/user-interaction/MultiSelect/useMultiSelect.ts rename to packages/hightide-web/src/components/data-input/MultiSelect/useMultiSelect.ts index f89d5cc6..e5c4962d 100644 --- a/packages/hightide-web/src/components/user-interaction/MultiSelect/useMultiSelect.ts +++ b/packages/hightide-web/src/components/data-input/MultiSelect/useMultiSelect.ts @@ -6,7 +6,7 @@ import { } from 'react' import { useMultiSelection } from '@helpwave/hightide-utils/hooks' import { useListNavigation } from '@helpwave/hightide-utils/hooks' -import { useEventCallbackStabilizer } from '@helpwave/hightide-utils/hooks' +import { useStableEvent } from '@helpwave/hightide-utils/hooks' import { useSearch } from '@helpwave/hightide-utils/hooks' import { useTypeAheadSearch } from '@helpwave/hightide-utils/hooks' @@ -84,8 +84,8 @@ export function useMultiSelect({ isControlled: controlledValue !== undefined, }) - const editCompleteStable = useEventCallbackStabilizer(onEditComplete) - const onCloseStable = useEventCallbackStabilizer(onClose) + const editCompleteStable = useStableEvent(onEditComplete) + const onCloseStable = useStableEvent(onClose) const { searchResult: visibleOptions } = useSearch({ items: options, diff --git a/packages/hightide-web/src/components/user-interaction/Select/Select.tsx b/packages/hightide-web/src/components/data-input/Select/Select.tsx similarity index 100% rename from packages/hightide-web/src/components/user-interaction/Select/Select.tsx rename to packages/hightide-web/src/components/data-input/Select/Select.tsx diff --git a/packages/hightide-web/src/components/user-interaction/Select/SelectComponent.tsx b/packages/hightide-web/src/components/data-input/Select/SelectComponent.tsx similarity index 97% rename from packages/hightide-web/src/components/user-interaction/Select/SelectComponent.tsx rename to packages/hightide-web/src/components/data-input/Select/SelectComponent.tsx index f0df5fac..5fad4e2d 100644 --- a/packages/hightide-web/src/components/user-interaction/Select/SelectComponent.tsx +++ b/packages/hightide-web/src/components/data-input/Select/SelectComponent.tsx @@ -20,7 +20,7 @@ type SelectComponentType = ( props: SelectProps & { ref?: React.ForwardedRef, } -) => React.ReactElement | null +) => React.ReactElement const SelectComponentImpl = forwardRef< HTMLDivElement, diff --git a/packages/hightide-web/src/components/user-interaction/Select/SelectContent.tsx b/packages/hightide-web/src/components/data-input/Select/SelectContent.tsx similarity index 66% rename from packages/hightide-web/src/components/user-interaction/Select/SelectContent.tsx rename to packages/hightide-web/src/components/data-input/Select/SelectContent.tsx index 01f25a35..f3929915 100644 --- a/packages/hightide-web/src/components/user-interaction/Select/SelectContent.tsx +++ b/packages/hightide-web/src/components/data-input/Select/SelectContent.tsx @@ -1,24 +1,22 @@ -import type { ComponentProps, ForwardedRef } from 'react' +import type { ForwardedRef } from 'react' import { forwardRef, useCallback, useEffect, useRef } from 'react' import { useSelectContext } from './SelectContext' import clsx from 'clsx' import { useHightideTranslation } from '@helpwave/hightide-utils/context/translation' -import { PopUp, type PopUpProps } from '../../layout/popup/PopUp' -import { Input } from '../input/Input' +import { PopUp, type PopUpProps } from '../../layout/PopUp/PopUp' +import { TextInput, type TextInputProps } from '../input/TextInput' import { Visibility } from '../../layout/Visibility' import { ReactUtils } from '@helpwave/hightide-utils/utils' export interface SelectContentProps extends PopUpProps { - showSearch?: boolean, - searchInputProps?: Omit, 'value' | 'onValueChange'>, + searchInputProps?: Omit, } export const SelectContent = forwardRef(function SelectContent({ - id, options, showSearch: showSearchOverride, searchInputProps, ...props + id, options, searchInputProps, ...props }: SelectContentProps, ref: ForwardedRef) { const translation = useHightideTranslation() const innerRef = useRef(null) - const searchInputRef = useRef(null) const context = useSelectContext() const { config, handleTypeaheadKey, toggleSelection, highlightNext, highlightPrevious, highlightFirst, highlightLast, highlightedId } = context @@ -28,7 +26,7 @@ export const SelectContent = forwardRef(fu if (id) setIds((prev) => ({ ...prev, content: id })) }, [id, setIds]) - const showSearch = showSearchOverride ?? context.search.hasSearch + const showSearch = context.search.hasSearch const listboxAriaLabel = showSearch ? translation('searchResults') : undefined const keyHandler = useCallback( @@ -85,23 +83,34 @@ export const SelectContent = forwardRef(fu className={clsx('select-content', props.className)} > {showSearch && ( - { + searchInputProps?.inputProps?.onKeyDown?.(event) + keyHandler(event) + }, + 'placeholder': searchInputProps?.inputProps?.placeholder ?? translation('filterOptions'), + 'role': 'combobox', + 'aria-autocomplete': 'list', + 'aria-expanded': context.isOpen, + 'aria-controls': context.config.ids.listbox, + 'aria-activedescendant': context.highlightedId + ? context.config.ids.listbox + '-' + context.highlightedId + : undefined, + 'aria-label': searchInputProps?.inputProps?.['aria-label'] ?? translation('filterOptions'), + 'className': clsx('mx-2 mt-2 shrink-0', searchInputProps?.inputProps?.className), + }} /> )}
        (fu aria-disabled={true} aria-live="polite" aria-atomic={true} - data-name="select-list-status" - className={clsx({ 'sr-only': context.visibleOptionIds.length > 0 })} + className={clsx('select-list-status', { 'sr-only': context.visibleOptionIds.length > 0 })} > {translation('nResultsFound', { count: context.visibleOptionIds.length })} diff --git a/packages/hightide-web/src/components/user-interaction/Select/SelectContext.tsx b/packages/hightide-web/src/components/data-input/Select/SelectContext.tsx similarity index 91% rename from packages/hightide-web/src/components/user-interaction/Select/SelectContext.tsx rename to packages/hightide-web/src/components/data-input/Select/SelectContext.tsx index 239773f8..774c10bb 100644 --- a/packages/hightide-web/src/components/user-interaction/Select/SelectContext.tsx +++ b/packages/hightide-web/src/components/data-input/Select/SelectContext.tsx @@ -1,7 +1,7 @@ import type { Dispatch, ReactNode, RefObject, SetStateAction } from 'react' import { createContext, useContext } from 'react' import type { UseSelectFirstHighlightBehavior } from './useSelect' -import type { FormFieldInteractionStates } from '../../form/FieldLayout' +import type { InputComponentInterface } from '../input/TextInput' export type SelectOptionIdentity = { value: T, @@ -23,7 +23,7 @@ export interface SelectContextIds { searchInput: string, } -export interface SelectContextState extends FormFieldInteractionStates { +export interface SelectContextState extends Required, 'invalid' | 'disabled' | 'readOnly' | 'required'>> { selectedId: string | null, options: ReadonlyArray>, highlightedId: string | null, diff --git a/packages/hightide-web/src/components/user-interaction/Select/SelectOption.tsx b/packages/hightide-web/src/components/data-input/Select/SelectOption.tsx similarity index 87% rename from packages/hightide-web/src/components/user-interaction/Select/SelectOption.tsx rename to packages/hightide-web/src/components/data-input/Select/SelectOption.tsx index c6780ec2..e96cac14 100644 --- a/packages/hightide-web/src/components/user-interaction/Select/SelectOption.tsx +++ b/packages/hightide-web/src/components/data-input/Select/SelectOption.tsx @@ -1,5 +1,7 @@ import clsx from 'clsx' import { CheckIcon } from 'lucide-react' +import { Icon } from '../../visualization/Icon' +import { ColoringUtils } from '../../../utils/coloring' import type React from 'react' import type { ForwardedRef, HTMLAttributes, RefObject } from 'react' import { createContext, forwardRef, useContext, useEffect, useMemo, useRef } from 'react' @@ -39,7 +41,7 @@ type SelectOptionComponent = ( props: SelectOptionProps & { ref?: React.ForwardedRef, } -) => React.ReactElement | null +) => React.ReactElement const toIdentity = (value: T, valueId: string | undefined): SelectOptionIdentity => { if (valueId === undefined) { @@ -101,7 +103,12 @@ const SelectOptionImpl = forwardRef< aria-selected={isSelected} aria-hidden={!isVisible} - data-name="select-list-option" + className={clsx('select-list-option', props.className)} + {...ColoringUtils.build({ + color: 'surface-inverse', + mode: 'interactive', + coloringStyle: 'foreground', + })} data-highlighted={isHighlighted ? '' : undefined} data-selected={isSelected ? '' : undefined} data-disabled={disabled ? '' : undefined} @@ -118,18 +125,21 @@ const SelectOptionImpl = forwardRef< context.highlightItem(optionId) props.onMouseEnter?.(event) } - }} - > + }}> {iconAppearanceResolved === 'left' && context.selectedId !== null && ( - )} {display} {iconAppearanceResolved === 'right' && context.selectedId !== null && ( - )} diff --git a/packages/hightide-web/src/components/user-interaction/Select/SelectRoot.tsx b/packages/hightide-web/src/components/data-input/Select/SelectRoot.tsx similarity index 78% rename from packages/hightide-web/src/components/user-interaction/Select/SelectRoot.tsx rename to packages/hightide-web/src/components/data-input/Select/SelectRoot.tsx index c2a81e07..1044394a 100644 --- a/packages/hightide-web/src/components/user-interaction/Select/SelectRoot.tsx +++ b/packages/hightide-web/src/components/data-input/Select/SelectRoot.tsx @@ -1,13 +1,12 @@ import type { ReactNode, RefObject, SetStateAction } from 'react' -import { useCallback, useEffect, useId, useMemo, useState } from 'react' +import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react' import { SelectContext } from './SelectContext' import type { SelectContextConfig, SelectContextLayout, SelectOptionType } from './SelectContext' import { useSelect } from './useSelect' import { DOMUtils } from '../../../utils/dom' -import type { FormFieldDataHandling } from '../../form/FormField' -import { useEventCallbackStabilizer } from '@helpwave/hightide-utils/hooks' -import type { FormFieldInteractionStates } from '../../form/FieldLayout' -import { PopUpContext } from '../../layout/popup/PopUpContext' +import type { InputComponentInterface } from '../input/TextInput' +import { useStableEvent } from '@helpwave/hightide-utils/hooks' +import { PopUpContext } from '../../layout/PopUp/PopUpContext' export interface SelectIds { trigger: string, @@ -16,14 +15,12 @@ export interface SelectIds { searchInput: string, } -export interface SelectRootProps extends Omit>, 'value'>, Partial { - value?: T | null, - initialValue?: T | null, - compareFunction?: (a: T | null, b: T | null) => boolean, +export interface SelectRootProps extends InputComponentInterface { + compareFunction?: (a: T, b: T) => boolean, initialIsOpen?: boolean, onClose?: () => void, onIsOpenChange?: (isOpen: boolean) => void, - showSearch?: boolean, + searchableThreshold?: number, iconAppearance?: 'left' | 'right' | 'none', children: ReactNode, } @@ -31,22 +28,24 @@ export interface SelectRootProps extends Omit({ children, value, - onValueChange, - onEditComplete, + onValueUpdate: onValueChange, + onValueCommit: onEditComplete, initialValue, compareFunction, initialIsOpen = false, onClose, onIsOpenChange, - showSearch = true, + searchableThreshold = 6, iconAppearance = 'right', invalid = false, disabled = false, readOnly = false, required = false, }: SelectRootProps) { - const [triggerRef, setTriggerRef] = useState | null>(null) + const nullTrigger = useRef(null) + const [triggerRef, setTriggerRef] = useState>(nullTrigger) const [options, setOptions] = useState[]>([]) + const [optionSnapshots, setOptionSnapshots] = useState>>({}) const generatedId = useId() const [ids, setIds] = useState({ trigger: 'select-' + generatedId, @@ -58,6 +57,7 @@ export function SelectRoot({ const registerOption = useCallback( (item: SelectOptionType) => { + setOptionSnapshots((previous) => ({ ...previous, [item.value.id]: item })) setOptions((prev) => { const next = prev.filter((o) => o.value.id !== item.value.id) next.push(item) @@ -71,10 +71,10 @@ export function SelectRoot({ [] ) - const registerTrigger = useCallback((ref: RefObject) => { + const registerTrigger = useCallback((ref: RefObject) => { setTriggerRef(ref) return () => { - setTriggerRef(null) + setTriggerRef(nullTrigger) } }, []) @@ -89,17 +89,17 @@ export function SelectRoot({ const mappedValueId = useMemo(() => { if(value === undefined) return undefined - return options.find((o) => compare(o.value.value, value))?.value.id ?? null + return options.find((o) => compare(o.value.value, value))?.value.id }, [options, value, compare]) const mappedInitialValueId = useMemo(() => { if(initialValue === undefined) return undefined - return options.find((o) => compare(o.value.value, initialValue))?.value.id ?? null + return options.find((o) => compare(o.value.value, initialValue))?.value.id }, [options, initialValue, compare]) - const onValueChangeStable = useEventCallbackStabilizer(onValueChange) - const onEditCompleteStable = useEventCallbackStabilizer(onEditComplete) - const onIsOpenChangeStable = useEventCallbackStabilizer(onIsOpenChange) + const onValueChangeStable = useStableEvent(onValueChange) + const onEditCompleteStable = useStableEvent(onEditComplete) + const onIsOpenChangeStable = useStableEvent(onIsOpenChange) const onValueChangeWrapper = useCallback((value: string) => { const option = idToOptionMap[value] @@ -121,8 +121,8 @@ export function SelectRoot({ const state = useSelect({ - value: mappedValueId, - initialValue: mappedInitialValueId, + value: value !== undefined && mappedValueId === undefined ? null : mappedValueId, + initialValue: initialValue !== undefined && mappedInitialValueId === undefined ? null : mappedInitialValueId, onValueChange: onValueChangeWrapper, onEditComplete: onEditCompleteWrapper, options: options.map((o) => ({ @@ -135,12 +135,14 @@ export function SelectRoot({ onIsOpenChange: onIsOpenChangeStable, }) const { setSearchQuery } = state + const knownOptionCount = Math.max(options.length, Object.keys(optionSnapshots).length) + const hasSearch = knownOptionCount >= searchableThreshold useEffect(() => { - if(showSearch === false) { + if (!hasSearch) { setSearchQuery('') } - }, [showSearch, setSearchQuery]) + }, [hasSearch, setSearchQuery]) const config: SelectContextConfig = useMemo(() => ({ iconAppearance, @@ -187,7 +189,7 @@ export function SelectRoot({ config, layout, search: { - hasSearch: showSearch, + hasSearch, searchQuery: state.searchQuery, setSearchQuery: state.setSearchQuery, }, diff --git a/packages/hightide-web/src/components/user-interaction/Select/SelectTrigger.tsx b/packages/hightide-web/src/components/data-input/Select/SelectTrigger.tsx similarity index 90% rename from packages/hightide-web/src/components/user-interaction/Select/SelectTrigger.tsx rename to packages/hightide-web/src/components/data-input/Select/SelectTrigger.tsx index 2155e68e..2599f02c 100644 --- a/packages/hightide-web/src/components/user-interaction/Select/SelectTrigger.tsx +++ b/packages/hightide-web/src/components/data-input/Select/SelectTrigger.tsx @@ -1,26 +1,26 @@ +import clsx from 'clsx' import type React from 'react' import type { ComponentPropsWithoutRef, ForwardedRef, ReactNode } from 'react' import { forwardRef, useEffect, useRef } from 'react' import type { SelectOptionType } from './SelectContext' import { useSelectContext } from './SelectContext' import { useHightideTranslation } from '@helpwave/hightide-utils/context/translation' -import { ExpansionIcon } from '../../display-and-visualization/ExpansionIcon' +import { ExpansionIcon } from '../../visualization/ExpansionIcon' import { SelectOptionDisplayContext } from './SelectOption' import { ReactUtils } from '@helpwave/hightide-utils/utils' export interface SelectTriggerProps extends ComponentPropsWithoutRef<'div'> { - 'placeholder'?: ReactNode, - 'disabled'?: boolean, - 'selectedDisplay'?: (value: SelectOptionType | null) => ReactNode, - 'hideExpansionIcon'?: boolean, - 'data-name'?: string, + placeholder?: ReactNode, + disabled?: boolean, + selectedDisplay?: (value: SelectOptionType | null) => ReactNode, + hideExpansionIcon?: boolean, } type SelectTriggerComponent = ( props: SelectTriggerProps & { ref?: React.ForwardedRef, } -) => React.ReactElement | null +) => React.ReactElement const SelectTriggerImpl = forwardRef< HTMLDivElement, @@ -89,7 +89,7 @@ const SelectTriggerImpl = forwardRef< break } }} - data-name={props['data-name'] ?? 'select-button'} + className={clsx('select-button input-element', props.className)} data-value={hasValue ? '' : undefined} data-disabled={disabled ? '' : undefined} data-readonly={readOnly ? '' : undefined} @@ -101,8 +101,7 @@ const SelectTriggerImpl = forwardRef< aria-readonly={readOnly} aria-haspopup="dialog" aria-expanded={context.isOpen} - aria-controls={context.isOpen ? context.config.ids.content : undefined} - > + aria-controls={context.isOpen ? context.config.ids.content : undefined}> {hasValue ? selectedDisplay?.(selectedOption) ?? (selectedOption?.display) diff --git a/packages/hightide-web/src/components/user-interaction/Select/index.ts b/packages/hightide-web/src/components/data-input/Select/index.ts similarity index 100% rename from packages/hightide-web/src/components/user-interaction/Select/index.ts rename to packages/hightide-web/src/components/data-input/Select/index.ts diff --git a/packages/hightide-web/src/components/user-interaction/Select/useSelect.ts b/packages/hightide-web/src/components/data-input/Select/useSelect.ts similarity index 93% rename from packages/hightide-web/src/components/user-interaction/Select/useSelect.ts rename to packages/hightide-web/src/components/data-input/Select/useSelect.ts index 55210935..d149e986 100644 --- a/packages/hightide-web/src/components/user-interaction/Select/useSelect.ts +++ b/packages/hightide-web/src/components/data-input/Select/useSelect.ts @@ -6,7 +6,7 @@ import { } from 'react' import { useSingleSelection } from '@helpwave/hightide-utils/hooks' import { useListNavigation } from '@helpwave/hightide-utils/hooks' -import { useEventCallbackStabilizer } from '@helpwave/hightide-utils/hooks' +import { useStableEvent } from '@helpwave/hightide-utils/hooks' import { useSearch } from '@helpwave/hightide-utils/hooks' import { useTypeAheadSearch } from '@helpwave/hightide-utils/hooks' @@ -71,10 +71,10 @@ export function useSelect({ const [isOpen, setIsOpen] = useState(initialIsOpen) const [searchQuery, setSearchQuery] = useState('') - const onValueChangeStable = useEventCallbackStabilizer(onValueChange) - const onEditCompleteStable = useEventCallbackStabilizer(onEditComplete) - const onCloseStable = useEventCallbackStabilizer(onClose) - const onIsOpenChangeStable = useEventCallbackStabilizer(onIsOpenChange) + const onValueChangeStable = useStableEvent(onValueChange) + const onEditCompleteStable = useStableEvent(onEditComplete) + const onCloseStable = useStableEvent(onClose) + const onIsOpenChangeStable = useStableEvent(onIsOpenChange) const onSelectionChangeWrapper = useCallback((id: string | null) => { if(id === null) return diff --git a/packages/hightide-web/src/components/data-input/Switch/Switch.tsx b/packages/hightide-web/src/components/data-input/Switch/Switch.tsx new file mode 100644 index 00000000..46b7ac16 --- /dev/null +++ b/packages/hightide-web/src/components/data-input/Switch/Switch.tsx @@ -0,0 +1,83 @@ +import type { ComponentRef, Ref } from 'react' +import { useCallback } from 'react' +import type { ControllableInputProps } from '@helpwave/hightide-utils/interfaces' +import { useControlledState, useStateMachineBinding } from '@helpwave/hightide-utils/hooks' +import { ReactUtils } from '@helpwave/hightide-utils/utils' +import { SwitchStateManager } from './SwitchStateManager' +import { SwitchThumb, type SwitchThumbProps } from './SwitchThumb' +import { SwitchTrack, type SwitchTrackProps } from './SwitchTrack' +import { SwitchTrigger, type SwitchTriggerProps } from './SwitchTrigger' +import type { SwitchEvent, SwitchState } from './SwitchState' + +export type SwitchProps = ControllableInputProps + & { + triggerRef?: Ref>, + triggerProps?: SwitchTriggerProps, + trackRef?: Ref>, + trackProps?: SwitchTrackProps, + thumbRef?: Ref>, + thumbProps?: SwitchThumbProps, + } + +const SwitchComponent = ({ + value, + initialValue, + onValueChange, + onStateEvent, + isInvalid, + isDisabled, + isReadOnly, + isRequired, + triggerRef, + triggerProps, + trackRef, + trackProps, + thumbRef, + thumbProps, +}: SwitchProps) => { + const [state, setState] = useControlledState({ + defaultValue: { value: initialValue ?? false }, + }) + const valueBinding = useStateMachineBinding({ + state, + onStateChange: setState, + onValueChange, + value, + inject: useCallback((next: boolean) => ({ value: next }), []), + get: useCallback((current: SwitchState) => current.value, []), + }) + + return ( + + + + + + + + ) +} + +export const Switch = Object.assign(SwitchComponent, { + StateManager: SwitchStateManager, + Trigger: SwitchTrigger, + Track: SwitchTrack, + Thumb: SwitchThumb, +}) diff --git a/packages/hightide-web/src/components/data-input/Switch/SwitchContext.tsx b/packages/hightide-web/src/components/data-input/Switch/SwitchContext.tsx new file mode 100644 index 00000000..e837e1be --- /dev/null +++ b/packages/hightide-web/src/components/data-input/Switch/SwitchContext.tsx @@ -0,0 +1,13 @@ +import { createContext, useContext } from 'react' +import type { InputContextValue } from '@helpwave/hightide-utils/interfaces' +import type { SwitchEvent, SwitchState } from './SwitchState' + +export type SwitchContextValue = InputContextValue + +export const SwitchContext = createContext(null) + +export function useSwitchContext(): SwitchContextValue { + const context = useContext(SwitchContext) + if (!context) throw new Error('useSwitchContext must be used inside a Switch.StateManager') + return context +} diff --git a/packages/hightide-web/src/components/data-input/Switch/SwitchState.ts b/packages/hightide-web/src/components/data-input/Switch/SwitchState.ts new file mode 100644 index 00000000..ffa851c1 --- /dev/null +++ b/packages/hightide-web/src/components/data-input/Switch/SwitchState.ts @@ -0,0 +1,14 @@ +export type SwitchState = { + value: boolean, +} + +export type SwitchEvent = { + type: 'toggle', +} + +export const switchStateTransition = (state: SwitchState, event: SwitchEvent): SwitchState => { + switch (event.type) { + case 'toggle': + return { value: !state.value } + } +} diff --git a/packages/hightide-web/src/components/data-input/Switch/SwitchStateManager.tsx b/packages/hightide-web/src/components/data-input/Switch/SwitchStateManager.tsx new file mode 100644 index 00000000..e7116abb --- /dev/null +++ b/packages/hightide-web/src/components/data-input/Switch/SwitchStateManager.tsx @@ -0,0 +1,53 @@ +import type { ReactNode } from 'react' +import { useCallback, useMemo } from 'react' +import type { InputStateMangerProps } from '@helpwave/hightide-utils/interfaces' +import { useStableEvent, useStateMachine } from '@helpwave/hightide-utils/hooks' +import { SwitchContext, type SwitchContextValue } from './SwitchContext' +import { switchStateTransition, type SwitchEvent, type SwitchState } from './SwitchState' + +export type SwitchStateManagerProps = InputStateMangerProps + & { + children?: ReactNode, + } + +export function SwitchStateManager({ + state, + onStateChange, + onStateEvent, + isInvalid = false, + isDisabled = false, + isReadOnly = false, + isRequired = false, + children, +}: SwitchStateManagerProps) { + const machine = useStateMachine({ + state, + transition: switchStateTransition, + onStateChange, + }) + + const onStateEventStable = useStableEvent(onStateEvent) + const dispatch = useCallback((event: SwitchEvent) => { + machine.dispatch(event) + onStateEventStable(event) + }, [machine, onStateEventStable]) + + const config = useMemo(() => ({ + isInvalid, + isDisabled, + isReadOnly, + isRequired, + }), [isDisabled, isInvalid, isReadOnly, isRequired]) + + const contextValue = useMemo(() => ({ + state: machine.state, + dispatch, + config, + }), [config, dispatch, machine.state]) + + return ( + + {children} + + ) +} diff --git a/packages/hightide-web/src/components/data-input/Switch/SwitchThumb.tsx b/packages/hightide-web/src/components/data-input/Switch/SwitchThumb.tsx new file mode 100644 index 00000000..ad512779 --- /dev/null +++ b/packages/hightide-web/src/components/data-input/Switch/SwitchThumb.tsx @@ -0,0 +1,23 @@ +import clsx from 'clsx' +import type { HTMLAttributes } from 'react' +import { forwardRef } from 'react' +import { PropsUtil } from '../../../utils/propsUtil' +import { useSwitchContext } from './SwitchContext' + +export type SwitchThumbProps = HTMLAttributes + +export const SwitchThumb = forwardRef(function SwitchThumb( + props, + forwardedRef +) { + const { state } = useSwitchContext() + + return ( +
        + ) +}) diff --git a/packages/hightide-web/src/components/data-input/Switch/SwitchTrack.tsx b/packages/hightide-web/src/components/data-input/Switch/SwitchTrack.tsx new file mode 100644 index 00000000..4e66b9d1 --- /dev/null +++ b/packages/hightide-web/src/components/data-input/Switch/SwitchTrack.tsx @@ -0,0 +1,20 @@ +import clsx from 'clsx' +import type { HTMLAttributes } from 'react' +import { forwardRef } from 'react' + +export type SwitchTrackProps = HTMLAttributes + +export const SwitchTrack = forwardRef(function SwitchTrack({ + children, + ...props +}, forwardedRef) { + return ( +
        + {children} +
        + ) +}) diff --git a/packages/hightide-web/src/components/data-input/Switch/SwitchTrigger.tsx b/packages/hightide-web/src/components/data-input/Switch/SwitchTrigger.tsx new file mode 100644 index 00000000..3edc9aa3 --- /dev/null +++ b/packages/hightide-web/src/components/data-input/Switch/SwitchTrigger.tsx @@ -0,0 +1,60 @@ +import clsx from 'clsx' +import type { HTMLAttributes } from 'react' +import { forwardRef } from 'react' +import { ColoringUtils } from '../../../utils/coloring' +import { PropsUtil } from '../../../utils/propsUtil' +import { useSwitchContext } from './SwitchContext' + +export type SwitchTriggerProps = HTMLAttributes + +export const SwitchTrigger = forwardRef(function SwitchTrigger({ + children, + ...props +}, forwardedRef) { + const { state, dispatch, config } = useSwitchContext() + const { isInvalid, isDisabled, isReadOnly, isRequired } = config + const isInteractive = !isDisabled && !isReadOnly + + return ( +
        { + props.onClick?.(event) + if (isInteractive) { + dispatch({ type: 'toggle' }) + } + }} + onKeyDown={(event) => { + props.onKeyDown?.(event) + if (!isInteractive) { + return + } + if (event.key === ' ' || event.key === 'Enter') { + event.preventDefault() + dispatch({ type: 'toggle' }) + } + }} + role="switch" + tabIndex={isDisabled ? -1 : 0} + aria-checked={state.value} + {...PropsUtil.aria.interactionStates({ + invalid: isInvalid, + disabled: isDisabled, + readOnly: isReadOnly, + required: isRequired, + }, props)} + {...ColoringUtils.dataColoringMode('interactive')} + className={clsx('switch', props.className)} + data-active={PropsUtil.dataAttributes.bool(state.value)} + {...PropsUtil.dataAttributes.interactionStates({ + invalid: isInvalid, + disabled: isDisabled, + readOnly: isReadOnly, + required: isRequired, + })} + > + {children} +
        + ) +}) diff --git a/packages/hightide-web/src/components/data-input/Switch/index.ts b/packages/hightide-web/src/components/data-input/Switch/index.ts new file mode 100644 index 00000000..d4e4134b --- /dev/null +++ b/packages/hightide-web/src/components/data-input/Switch/index.ts @@ -0,0 +1,8 @@ +export { Switch, type SwitchProps } from './Switch' +export { SwitchContext, useSwitchContext } from './SwitchContext' +export { SwitchStateManager, type SwitchStateManagerProps } from './SwitchStateManager' +export { SwitchTrigger, type SwitchTriggerProps } from './SwitchTrigger' +export { SwitchTrack, type SwitchTrackProps } from './SwitchTrack' +export { SwitchThumb, type SwitchThumbProps } from './SwitchThumb' +export type { SwitchState, SwitchEvent } from './SwitchState' +export type { SwitchContextValue } from './SwitchContext' diff --git a/packages/hightide-web/src/components/data-input/Textarea/Textarea.tsx b/packages/hightide-web/src/components/data-input/Textarea/Textarea.tsx new file mode 100644 index 00000000..4d29e0db --- /dev/null +++ b/packages/hightide-web/src/components/data-input/Textarea/Textarea.tsx @@ -0,0 +1,65 @@ +import type { ComponentRef, Ref } from 'react' +import { useCallback } from 'react' +import type { ControllableInputProps } from '@helpwave/hightide-utils/interfaces' +import { useControlledState, useStateMachineBinding } from '@helpwave/hightide-utils/hooks' +import { ReactUtils } from '@helpwave/hightide-utils/utils' +import { TextareaContext } from './TextareaContext' +import { TextareaElement, type TextareaElementProps } from './TextareaElement' +import { TextareaStateManager } from './TextareaStateManager' +import type { TextareaEvent, TextareaState } from './TextareaState' + +export type TextareaProps = ControllableInputProps + & { + inputRef?: Ref>, + inputProps?: TextareaElementProps, + } + +const TextareaComponent = ({ + value, + initialValue, + onValueChange, + onStateEvent, + isInvalid, + isDisabled, + isReadOnly, + isRequired, + inputRef, + inputProps, +}: TextareaProps) => { + const [state, setState] = useControlledState({ + defaultValue: { value: initialValue ?? '' }, + }) + const valueBinding = useStateMachineBinding({ + state, + onStateChange: setState, + onValueChange, + value, + inject: useCallback((next: string) => ({ value: next }), []), + get: useCallback((current: TextareaState) => current.value, []), + }) + + return ( + + + + ) +} + +export const Textarea = Object.assign(TextareaComponent, { + StateManager: TextareaStateManager, + Input: TextareaElement, + Context: TextareaContext, + Provider: TextareaContext.Provider, + Consumer: TextareaContext.Consumer, +}) diff --git a/packages/hightide-web/src/components/data-input/Textarea/TextareaContext.tsx b/packages/hightide-web/src/components/data-input/Textarea/TextareaContext.tsx new file mode 100644 index 00000000..3b598a4c --- /dev/null +++ b/packages/hightide-web/src/components/data-input/Textarea/TextareaContext.tsx @@ -0,0 +1,13 @@ +import { createContext, useContext } from 'react' +import type { InputContextValue } from '@helpwave/hightide-utils/interfaces' +import type { TextareaEvent, TextareaState } from './TextareaState' + +export type TextareaContextValue = InputContextValue + +export const TextareaContext = createContext(null) + +export function useTextareaContext(): TextareaContextValue { + const context = useContext(TextareaContext) + if (!context) throw new Error('useTextareaContext must be used inside a Textarea.StateManager') + return context +} diff --git a/packages/hightide-web/src/components/data-input/Textarea/TextareaElement.tsx b/packages/hightide-web/src/components/data-input/Textarea/TextareaElement.tsx new file mode 100644 index 00000000..8ac15acd --- /dev/null +++ b/packages/hightide-web/src/components/data-input/Textarea/TextareaElement.tsx @@ -0,0 +1,63 @@ +import clsx from 'clsx' +import type { TextareaHTMLAttributes } from 'react' +import { forwardRef } from 'react' +import { PropsUtil } from '../../../utils/propsUtil' +import type { DataAttributes } from '../../../utils' +import { useTextareaContext } from './TextareaContext' + +const Element = 'textarea' + +export type TextareaElementProps = TextareaHTMLAttributes & DataAttributes + +export const TextareaElement = forwardRef(function TextareaElement( + props, + forwardedRef +) { + const { state, dispatch, config } = useTextareaContext() + const { isInvalid, isDisabled, isReadOnly, isRequired } = config + + return ( + { + props.onFocus?.(event) + dispatch({ type: 'focus' }) + }} + onBlur={event => { + props.onBlur?.(event) + dispatch({ type: 'blur' }) + }} + onCompositionStart={event => { + props.onCompositionStart?.(event) + dispatch({ type: 'compositionStart' }) + }} + onCompositionEnd={event => { + props.onCompositionEnd?.(event) + dispatch({ type: 'compositionEnd' }) + }} + onChange={event => { + props.onChange?.(event) + dispatch({ type: 'change', value: event.target.value }) + }} + className={clsx('textarea input-element', props.className)} + data-value={PropsUtil.dataAttributes.bool(!!state.value)} + {...PropsUtil.dataAttributes.interactionStates({ + invalid: isInvalid, + disabled: isDisabled, + readOnly: isReadOnly, + required: isRequired, + })} + {...PropsUtil.aria.interactionStates({ + invalid: isInvalid, + disabled: isDisabled, + readOnly: isReadOnly, + required: isRequired, + }, props)} + /> + ) +}) diff --git a/packages/hightide-web/src/components/data-input/Textarea/TextareaState.ts b/packages/hightide-web/src/components/data-input/Textarea/TextareaState.ts new file mode 100644 index 00000000..a81b48cd --- /dev/null +++ b/packages/hightide-web/src/components/data-input/Textarea/TextareaState.ts @@ -0,0 +1,22 @@ +export type TextareaState = { + value: string, +} + +export type TextareaEvent = + | { type: 'change', value: string } + | { type: 'focus' } + | { type: 'blur' } + | { type: 'compositionStart' } + | { type: 'compositionEnd' } + +export const textareaStateTransition = (state: TextareaState, event: TextareaEvent): TextareaState => { + switch (event.type) { + case 'change': + return { value: event.value } + case 'focus': + case 'blur': + case 'compositionStart': + case 'compositionEnd': + return state + } +} diff --git a/packages/hightide-web/src/components/data-input/Textarea/TextareaStateManager.tsx b/packages/hightide-web/src/components/data-input/Textarea/TextareaStateManager.tsx new file mode 100644 index 00000000..101588dd --- /dev/null +++ b/packages/hightide-web/src/components/data-input/Textarea/TextareaStateManager.tsx @@ -0,0 +1,53 @@ +import type { ReactNode } from 'react' +import { useCallback, useMemo } from 'react' +import type { InputStateMangerProps } from '@helpwave/hightide-utils/interfaces' +import { useStableEvent, useStateMachine } from '@helpwave/hightide-utils/hooks' +import { TextareaContext, type TextareaContextValue } from './TextareaContext' +import { textareaStateTransition, type TextareaEvent, type TextareaState } from './TextareaState' + +export type TextareaStateManagerProps = InputStateMangerProps + & { + children?: ReactNode, + } + +export function TextareaStateManager({ + state, + onStateChange, + onStateEvent, + isInvalid = false, + isDisabled = false, + isReadOnly = false, + isRequired = false, + children, +}: TextareaStateManagerProps) { + const machine = useStateMachine({ + state, + transition: textareaStateTransition, + onStateChange, + }) + + const onStateEventStable = useStableEvent(onStateEvent) + const dispatch = useCallback((event: TextareaEvent) => { + machine.dispatch(event) + onStateEventStable(event) + }, [machine, onStateEventStable]) + + const config = useMemo(() => ({ + isInvalid, + isDisabled, + isReadOnly, + isRequired, + }), [isDisabled, isInvalid, isReadOnly, isRequired]) + + const contextValue = useMemo(() => ({ + state: machine.state, + dispatch, + config, + }), [config, dispatch, machine.state]) + + return ( + + {children} + + ) +} diff --git a/packages/hightide-web/src/components/data-input/Textarea/TextareaWithHeadline.tsx b/packages/hightide-web/src/components/data-input/Textarea/TextareaWithHeadline.tsx new file mode 100644 index 00000000..17288db9 --- /dev/null +++ b/packages/hightide-web/src/components/data-input/Textarea/TextareaWithHeadline.tsx @@ -0,0 +1,55 @@ +import type { LabelHTMLAttributes, ReactNode } from 'react' +import { useId } from 'react' +import clsx from 'clsx' +import { Textarea, type TextareaProps } from './Textarea' + +export type TextareaWithHeadlineProps = TextareaProps & { + headline: ReactNode, + headlineProps: Omit, 'children'>, + containerClassName?: string, + id?: string, +} + +export const TextareaWithHeadline = ({ + id, + headline, + headlineProps, + isDisabled = false, + inputProps, + containerClassName, + ...props +}: TextareaWithHeadlineProps) => { + const genId = useId() + const usedId = id ?? inputProps?.id ?? genId + + return ( +
        + {headline && ( + + )} +