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..12b827a6 --- /dev/null +++ b/packages/hightide-utils/src/form/FormFieldWrapper.tsx @@ -0,0 +1,71 @@ +import type { FormValues, ValidationRunnerState } from './FormStore' +import { useFormStore } from './FormStoreContext' + +const emptyFieldErrors: string[] = [] + +export type FormFieldWrapperConfig< + T extends FormValues, +> = { + isRequired?: boolean | ((values: T) => boolean), + isDisabled?: boolean | ((values: T) => boolean), +} + +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, +}; + +type FormFieldWrapperProps = { + name: K, + config?: FormFieldWrapperConfig, + children: (field: FormFieldWrapperBag) => React.ReactNode, +}; + +export function FormFieldWrapper< + T extends FormValues, + K extends keyof T +>({ + 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 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), + }) +} \ 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..eee3a6c4 --- /dev/null +++ b/packages/hightide-utils/src/form/index.ts @@ -0,0 +1,3 @@ +export * from './FormFieldWrapper' +export * from './FormStore' +export * from './FormStoreContext' 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..662eec71 --- /dev/null +++ b/packages/hightide-utils/src/hooks/useStateMachine.ts @@ -0,0 +1,51 @@ +import { useCallback, useMemo, useState } from 'react' +import { useStableEvent } from './useStableEvent' + +export type StateMachineProps = { + initialState: () => S, + transition: (state: S, event: E) => S, + state?: S, + onStateEvent?: (event: E) => void, + onStateChange?: (state: S) => void, +} + +export type StateMachine = { + state: S, + dispatch: (event: E) => void, + controlled: boolean, +} + +export function useStateMachine({ + initialState, + transition, + state: controlledState, + onStateEvent, + onStateChange, +}: StateMachineProps): StateMachine { + const [internalState, setInternalState] = useState(initialState) + + const isControlled = controlledState !== undefined + const state = isControlled ? controlledState : internalState + + const transitionStable = useStableEvent(transition) + const onStateEventStable = useStableEvent(onStateEvent) + const onStateChangeStable = useStableEvent(onStateChange) + + const dispatch = useCallback((event: E) => { + onStateEventStable(event) + + const nextState = transitionStable(state, event) + + if (!isControlled) { + setInternalState(nextState) + } + + onStateChangeStable(nextState) + }, [isControlled, onStateChangeStable, onStateEventStable, state, transitionStable]) + + return useMemo(() => ({ + state, + dispatch, + controlled: isControlled, + }), [isControlled, 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..90b875e0 --- /dev/null +++ b/packages/hightide-utils/src/hooks/useStateMachineBinding.ts @@ -0,0 +1,53 @@ +import { useCallback, useMemo } from 'react' +import { useStableEvent } from './useStableEvent' + +export type StateMachineBinding = { + get: (state: S) => V, + set: (value: V) => E, +} + +export type StateMachineBindingOptions = { + state: S, + dispatch: (event: E) => void, + binding: StateMachineBinding, + value?: V, + defaultValue: V, + onChange?: (value: V) => void, +} + +export type StateMachineBoundState = { + value: V, + setValue: (value: V) => void, + controlled: boolean, +} + +export function useStateMachineBinding({ + state, + dispatch, + binding, + value: controlledValue, + onChange, +}: StateMachineBindingOptions): StateMachineBoundState { + const value = controlledValue !== undefined + ? controlledValue + : binding.get(state) + const isControlled = controlledValue !== undefined + + const onChangeStable = useStableEvent(onChange) + const dispatchStable = useStableEvent(dispatch) + const setEventStable = useStableEvent(binding.set) + + const setValue = useCallback((nextValue: V) => { + onChangeStable(nextValue) + + if (!isControlled) { + dispatchStable(setEventStable(nextValue)) + } + }, [isControlled, dispatchStable, onChangeStable, setEventStable]) + + return useMemo(() => ({ + value, + setValue, + controlled: isControlled, + }), [isControlled, setValue, value]) +} 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/controllable-state.ts b/packages/hightide-utils/src/interfaces/controllable-state.ts new file mode 100644 index 00000000..f34747cb --- /dev/null +++ b/packages/hightide-utils/src/interfaces/controllable-state.ts @@ -0,0 +1,6 @@ +export interface ControllableStateProps { + initialState?: T, + state?: T, + onStateChange?: (state: T) => void, + onStateEvent?: (event: E) => void, +} \ No newline at end of file diff --git a/packages/hightide-utils/src/interfaces/index.ts b/packages/hightide-utils/src/interfaces/index.ts new file mode 100644 index 00000000..8b3e47a9 --- /dev/null +++ b/packages/hightide-utils/src/interfaces/index.ts @@ -0,0 +1,2 @@ +export * from './input' +export * from './controllable-state' diff --git a/packages/hightide-utils/src/interfaces/input.ts b/packages/hightide-utils/src/interfaces/input.ts new file mode 100644 index 00000000..6363d9e9 --- /dev/null +++ b/packages/hightide-utils/src/interfaces/input.ts @@ -0,0 +1,25 @@ +import type { ControllableStateProps } from './controllable-state' + +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, +} + +export interface ControllableStateInputProps extends + ControllableInputProps, + ControllableStateProps {} \ No newline at end of file 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/user-interaction/Checkbox.tsx b/packages/hightide-web/src/components/data-input/Checkbox.tsx similarity index 72% rename from packages/hightide-web/src/components/user-interaction/Checkbox.tsx rename to packages/hightide-web/src/components/data-input/Checkbox.tsx index f4cfa025..c017fe86 100644 --- a/packages/hightide-web/src/components/user-interaction/Checkbox.tsx +++ b/packages/hightide-web/src/components/data-input/Checkbox.tsx @@ -1,24 +1,25 @@ +import clsx from 'clsx' import { Check, Minus } from 'lucide-react' +import type { IconSize } from '../visualization/Icon' +import { Icon } from '../visualization/Icon' import { useCallback, type HTMLAttributes } from 'react' import { Visibility } from '../layout/Visibility' -import { PropsUtil } from '../../utils/propsUtil' -import type { FormFieldInteractionStates } from '../form/FieldLayout' -import type { FormFieldDataHandling } from '../form/FormField' +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' + +import { ColoringUtils } from '../../utils/coloring' +import { PropsUtil } from '../../utils/propsUtil' type CheckBoxSize = 'sm' | 'md' | 'lg' | null export type CheckboxProps = HTMLAttributes - & Partial - & Partial> + & InputComponentInterface & { - initialValue?: boolean, indeterminate?: boolean, size?: CheckBoxSize, alwaysShowCheckIcon?: boolean, isRounded?: boolean, - interactive?: boolean, } /** @@ -34,15 +35,15 @@ export const Checkbox = ({ invalid = false, disabled = false, readOnly = false, - onValueChange, - onEditComplete, + onValueUpdate: onValueChange, + onValueCommit: onEditComplete, size = 'md', alwaysShowCheckIcon = false, isRounded = false, ...props }: CheckboxProps) => { - const onEditCompleteStable = useEventCallbackStabilizer(onEditComplete) - const onValueChangeStable = useEventCallbackStabilizer(onValueChange) + const onEditCompleteStable = useStableEvent(onEditComplete) + const onValueChangeStable = useStableEvent(onValueChange) const onChangeWrapper = useCallback((value: boolean) => { onValueChangeStable(value) onEditCompleteStable(value) @@ -55,11 +56,11 @@ export const Checkbox = ({ }) const interactive = !disabled && !readOnly + const indicatorSize: IconSize = size === 'sm' ? 'sm' : size === 'lg' ? 'md' : 'sm' return (
{ if (interactive) { setValue(prev => !prev) @@ -87,13 +88,13 @@ export const Checkbox = ({ aria-checked={interactive ? (indeterminate ? 'mixed' : value) : undefined} {...PropsUtil.aria.interactionStates({ disabled, invalid, readOnly, required }, props)} - data-name="checkbox" - > + {...ColoringUtils.dataColoringMode('interactive')} + className={clsx('checkbox', props.className)}> - + - +
) 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 86% rename from packages/hightide-web/src/components/user-interaction/LabelledCheckbox.tsx rename to packages/hightide-web/src/components/data-input/LabelledCheckbox.tsx index a1f3b0aa..5bb84ac2 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) 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 66% 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..c2aca9cc 100644 --- a/packages/hightide-web/src/components/user-interaction/MultiSelect/MultiSelectContent.tsx +++ b/packages/hightide-web/src/components/data-input/MultiSelect/MultiSelectContent.tsx @@ -1,23 +1,22 @@ -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() @@ -32,7 +31,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 +94,36 @@ 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 +145,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 65% 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..418ece40 100644 --- a/packages/hightide-web/src/components/user-interaction/Select/SelectContent.tsx +++ b/packages/hightide-web/src/components/data-input/Select/SelectContent.tsx @@ -1,20 +1,19 @@ -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) @@ -28,7 +27,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 +84,38 @@ 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/user-interaction/Switch.tsx b/packages/hightide-web/src/components/data-input/Switch.tsx similarity index 67% rename from packages/hightide-web/src/components/user-interaction/Switch.tsx rename to packages/hightide-web/src/components/data-input/Switch.tsx index 1223a763..8f5006c2 100644 --- a/packages/hightide-web/src/components/user-interaction/Switch.tsx +++ b/packages/hightide-web/src/components/data-input/Switch.tsx @@ -1,16 +1,13 @@ +import clsx from 'clsx' import { type HTMLAttributes, useCallback } from 'react' -import type { FormFieldInteractionStates } from '../form/FieldLayout' -import type { FormFieldDataHandling } from '../form/FormField' +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' +import { ColoringUtils } from '../../utils/coloring' import { PropsUtil } from '../../utils/propsUtil' export type SwitchProps = HTMLAttributes - & Partial - & Partial> - & { - initialValue?: boolean, - } + & InputComponentInterface /** * A binary on/off switch @@ -24,12 +21,12 @@ export const Switch = ({ invalid = false, disabled = false, readOnly = false, - onValueChange, - onEditComplete, + onValueUpdate: onValueChange, + onValueCommit: onEditComplete, ...props }: SwitchProps) => { - const onEditCompleteStable = useEventCallbackStabilizer(onEditComplete) - const onValueChangeStable = useEventCallbackStabilizer(onValueChange) + const onEditCompleteStable = useStableEvent(onEditComplete) + const onValueChangeStable = useStableEvent(onValueChange) const onChangeWrapper = useCallback((value: boolean) => { onValueChangeStable(!value) @@ -45,7 +42,6 @@ export const Switch = ({ return (
        { if (!disabled && !readOnly) { setValue(prev => !prev) @@ -66,12 +62,12 @@ export const Switch = ({ aria-checked={value} {...PropsUtil.aria.interactionStates({ disabled, invalid, readOnly, required }, props)} - data-name={props['data-name'] ?? 'switch'} + {...ColoringUtils.dataColoringMode('interactive')} + className={clsx('switch', props.className)} data-active={PropsUtil.dataAttributes.bool(value)} - {...PropsUtil.dataAttributes.interactionStates({ disabled, invalid, readOnly, required })} - > -
        -
        + {...PropsUtil.dataAttributes.interactionStates({ disabled, invalid, readOnly, required })}> +
        +
        ) diff --git a/packages/hightide-web/src/components/user-interaction/Textarea.tsx b/packages/hightide-web/src/components/data-input/Textarea.tsx similarity index 85% rename from packages/hightide-web/src/components/user-interaction/Textarea.tsx rename to packages/hightide-web/src/components/data-input/Textarea.tsx index d230dae3..99bcee3b 100644 --- a/packages/hightide-web/src/components/user-interaction/Textarea.tsx +++ b/packages/hightide-web/src/components/data-input/Textarea.tsx @@ -2,17 +2,15 @@ import type { LabelHTMLAttributes, ReactNode, TextareaHTMLAttributes } from 'rea import { forwardRef, useCallback, useId } from 'react' import clsx from 'clsx' import { useDelay, type UseDelayOptions } from '@helpwave/hightide-utils/hooks' -import type { FormFieldInteractionStates } from '../form/FieldLayout' -import type { FormFieldDataHandling } from '../form/FormField' -import { PropsUtil } from '../../utils/propsUtil' +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' + +import { PropsUtil } from '../../utils/propsUtil' export type TextareaProps = Omit, 'value'> - & Partial> - & Partial + & InputComponentInterface & { - initialValue?: string, saveDelayOptions?: UseDelayOptions, } @@ -25,8 +23,8 @@ export const Textarea = forwardRef(function value: controlledValue, initialValue, invalid = false, - onValueChange, - onEditComplete, + onValueUpdate: onValueChange, + onValueCommit: onEditComplete, saveDelayOptions, ...props }, ref) { @@ -37,7 +35,7 @@ export const Textarea = forwardRef(function }) const { restartTimer, clearTimer } = useDelay(saveDelayOptions) - const onEditCompleteStable = useEventCallbackStabilizer(onEditComplete) + const onEditCompleteStable = useStableEvent(onEditComplete) const onEditCompleteWrapper = useCallback((text: string) => { onEditCompleteStable(text) clearTimer() @@ -62,16 +60,14 @@ export const Textarea = forwardRef(function onEditCompleteWrapper(event.target.value) }} - data-name={props['data-name'] ?? 'textarea'} + className={clsx('textarea input-element', props.className)} data-value={PropsUtil.dataAttributes.bool(!!value)} {...PropsUtil.dataAttributes.interactionStates({ ...props, invalid })} - {...PropsUtil.aria.interactionStates({ ...props, invalid }, props)} - /> + {...PropsUtil.aria.interactionStates({ ...props, invalid }, props)}/> ) }) - export type TextareaWithHeadlineProps = Omit & { headline: ReactNode, headlineProps: Omit, 'children'>, diff --git a/packages/hightide-web/src/components/user-interaction/date/DatePicker.tsx b/packages/hightide-web/src/components/data-input/date/CalendarDatePicker.tsx similarity index 53% rename from packages/hightide-web/src/components/user-interaction/date/DatePicker.tsx rename to packages/hightide-web/src/components/data-input/date/CalendarDatePicker.tsx index ef2d2035..79b500ae 100644 --- a/packages/hightide-web/src/components/user-interaction/date/DatePicker.tsx +++ b/packages/hightide-web/src/components/data-input/date/CalendarDatePicker.tsx @@ -1,48 +1,51 @@ -import { useState } from 'react' +import { useState, type ReactNode } from 'react' import { ArrowDown, ArrowUp, Calendar, ChevronDown } from 'lucide-react' import { DateUtils } from '@helpwave/hightide-utils/utils' import { LocalizationUtil } from '@helpwave/hightide-utils/i18n' import clsx from 'clsx' -import type { DayPickerProps } from './DayPicker' -import { DayPicker } from './DayPicker' +import type { CalendarDayPickerProps } from './CalendarDayPicker' +import { CalendarDayPicker } from './CalendarDayPicker' import type { YearMonthPickerProps } from './YearMonthPicker' import { YearMonthPicker } from './YearMonthPicker' import { useLocalization } from '../../../global-contexts/localization/forward-exports' -import { Button } from '../Button' -import type { FormFieldDataHandling } from '../../form/FormField' +import { Button } from '../../interaction/Button' +import type { InputComponentInterface } from '../input/TextInput' import { useControlledState } from '@helpwave/hightide-utils/hooks' -import { IconButton } from '../IconButton' +import { IconButton } from '../../interaction/IconButton' import { useHightideTranslation } from '@helpwave/hightide-utils/context/translation' -type DisplayMode = 'yearMonth' | 'day' +type DisplayMode = 'yearMonth' | 'day' | 'time' -export interface DatePickerProps extends - Partial>, - Pick +export interface CalendarDatePickerProps extends + InputComponentInterface, + Pick { - initialValue?: Date, initialDisplay?: DisplayMode, - dayPickerProps?: Omit, + calendarDayPickerProps?: Omit, yearMonthPickerProps?: Omit, + timeLabel: string, + timePicker?: ReactNode, className?: string, } /** * A Component for picking a date */ -export const DatePicker = ({ +export const CalendarDatePicker = ({ value: controlledValue, initialValue = new Date(), start, end, initialDisplay = 'day', weekStart, - onValueChange, - onEditComplete, + onValueUpdate: onValueChange, + onValueCommit: onEditComplete, yearMonthPickerProps, - dayPickerProps, + calendarDayPickerProps, + timeLabel, + timePicker, className -}: DatePickerProps) => { +}: CalendarDatePickerProps) => { const translation = useHightideTranslation() const { locale } = useLocalization() const [value, setValue] = useControlledState({ @@ -56,21 +59,33 @@ export const DatePicker = ({ const isDayMode = displayMode === 'day' return ( -
        -
        - +
        +
        +
        + + {timePicker !== undefined && ( + + )} +
        { const newDate = new Date() @@ -78,9 +93,8 @@ export const DatePicker = ({ setValue(newDate) setDisplayedMonth(newDate) }} - > - - + icon={Calendar} + /> { setDisplayedMonth(DateUtils.subtractDuration(displayedMonth, { months: 1 })) }} - > - - + icon={ArrowUp} + /> { setDisplayedMonth(DateUtils.addDuration(displayedMonth, { months: 1 })) }} - > - - + icon={ArrowDown} + />
        {displayMode === 'yearMonth' ? ( @@ -109,28 +121,32 @@ export const DatePicker = ({ value={value} start={start} end={end} - onValueChange={newDate => { - setDisplayedMonth(newDate) - setDisplayMode('day') + onValueUpdate={newDate => { + setValue(newDate) + setDisplayedMonth(new Date(newDate.getFullYear(), newDate.getMonth(), 1)) }} - onEditComplete={newDate => { - setDisplayedMonth(newDate) - setDisplayMode('day') + onValueCommit={newDate => { + setDisplayedMonth(new Date(newDate.getFullYear(), newDate.getMonth(), 1)) + onEditComplete?.(newDate) }} - className="date-picker-content" + className="calendar-date-picker-content" /> + ) : displayMode === 'time' ? ( +
        + {timePicker} +
        ) : ( - )}
        diff --git a/packages/hightide-web/src/components/user-interaction/date/DayPicker.tsx b/packages/hightide-web/src/components/data-input/date/CalendarDayPicker.tsx similarity index 84% rename from packages/hightide-web/src/components/user-interaction/date/DayPicker.tsx rename to packages/hightide-web/src/components/data-input/date/CalendarDayPicker.tsx index 930e44cc..9339877f 100644 --- a/packages/hightide-web/src/components/user-interaction/date/DayPicker.tsx +++ b/packages/hightide-web/src/components/data-input/date/CalendarDayPicker.tsx @@ -1,14 +1,16 @@ +import clsx from 'clsx' import type { KeyboardEvent } from 'react' import type { Weekday } from '@helpwave/hightide-utils/utils' import { DateUtils } from '@helpwave/hightide-utils/utils' import { useLocalization } from '../../../global-contexts/localization/forward-exports' -import type { FormFieldDataHandling } from '../../form/FormField' -import { PropsUtil } from '../../../utils/propsUtil' +import type { InputComponentInterface } from '../input/TextInput' import { useCallback, useEffect, useMemo, useRef } from 'react' import { useControlledState } from '@helpwave/hightide-utils/hooks' -export type DayPickerProps = Partial> & { - initialValue?: Date, +import { ColoringUtils } from '../../../utils/coloring' +import { PropsUtil } from '../../../utils/propsUtil' + +export type CalendarDayPickerProps = InputComponentInterface & { displayedMonth?: Date, changeDisplayedMonth?: (date: Date) => void, initialDisplayedMonth?: Date, @@ -22,7 +24,7 @@ export type DayPickerProps = Partial> & { /** * A component for selecting a day of a month */ -export const DayPicker = ({ +export const CalendarDayPicker = ({ displayedMonth: controlledDisplayedMonth, initialDisplayedMonth, changeDisplayedMonth, @@ -30,12 +32,12 @@ export const DayPicker = ({ initialValue = new Date(), start: providedStart, end: providedEnd, - onValueChange, - onEditComplete, + onValueUpdate: onValueChange, + onValueCommit: onEditComplete, weekStart: weekStartOverride, markToday = true, className, -}: DayPickerProps) => { +}: CalendarDayPickerProps) => { const { locale, startingWeekday: contextStartingWeekday } = useLocalization() const weekStart = weekStartOverride ?? contextStartingWeekday @@ -85,8 +87,6 @@ export const DayPicker = ({ return date }, [start, end]) - - const navigateTo = useCallback((candidate: Date) => { const clamped = clampToRange(candidate) if (!DateUtils.between(clamped, start, end)) return @@ -109,18 +109,17 @@ export const DayPicker = ({ [focusTargetDate, navigateTo] ) - return ( -
        -
        +
        +
        {weeks[0]!.map((weekDay, index) => ( -
        +
        {new Intl.DateTimeFormat(locale, { weekday: 'long' }).format(weekDay).substring(0, 2)}
        ))}
        {weeks.map((week, index) => ( -
        +
        {week.map((date) => { const isSelected = !!value && DateUtils.equalDate(value, date) const isFocused = !!focusTargetDate && DateUtils.equalDate(focusTargetDate, date) @@ -131,7 +130,7 @@ export const DayPicker = ({
        { if (!isDayValid) return const newDate = new Date( @@ -154,8 +153,13 @@ export const DayPicker = ({ role="button" tabIndex={isFocused ? 0 : -1} + {...ColoringUtils.build({ + color: isSelected ? 'primary' : isSameMonth ? 'surface-inverse' : 'description', + mode: isDayValid ? 'interactive' : 'static', + coloringStyle: isSelected ? 'filled' : 'foreground', + })} + data-disabled={PropsUtil.dataAttributes.bool(!isDayValid)} data-selected={PropsUtil.dataAttributes.bool(isSelected)} - data-invalid={PropsUtil.dataAttributes.bool(!isDayValid)} data-today={PropsUtil.dataAttributes.bool(isToday && markToday)} data-samemonth={PropsUtil.dataAttributes.bool(isSameMonth)} > @@ -168,4 +172,3 @@ export const DayPicker = ({
        ) } - diff --git a/packages/hightide-web/src/components/data-input/date/DateTimePicker.tsx b/packages/hightide-web/src/components/data-input/date/DateTimePicker.tsx new file mode 100644 index 00000000..ba82dd01 --- /dev/null +++ b/packages/hightide-web/src/components/data-input/date/DateTimePicker.tsx @@ -0,0 +1,113 @@ +import type { HTMLAttributes } from 'react' +import { type ReactNode } from 'react' +import type { DateTimeFormat } from '@helpwave/hightide-utils/utils' +import { useControlledState } from '@helpwave/hightide-utils/hooks' +import clsx from 'clsx' + +import { useDateTimeFormat, useLocalization } from '../../../global-contexts/localization/forward-exports' +import type { InputComponentInterface } from '../input/TextInput' +import type { CalendarDatePickerProps } from './CalendarDatePicker' +import { CalendarDatePicker } from './CalendarDatePicker' +import type { TimeWheelPickerProps } from './TimeWheelPicker' +import { TimeWheelPicker } from './TimeWheelPicker' + +export interface DateTimePickerProps extends +HTMLAttributes, +InputComponentInterface, +Pick, +Pick +{ + mode?: DateTimeFormat, + calendarDatePickerProps?: Omit, + timeInputProps?: Omit, +} + +/** + * A Component for picking a Date and Time + */ +export const DateTimePicker = ({ + value: controlledValue, + initialValue = new Date(), + start, + end, + mode = 'dateTime', + is24HourFormat, + minuteIncrement, + weekStart, + secondIncrement, + millisecondIncrement, + precision, + onValueUpdate: onValueChange, + onValueCommit: onEditComplete, + timeInputProps, + calendarDatePickerProps, + ...props +}: DateTimePickerProps) => { + const useDate = mode === 'dateTime' || mode === 'date' + const useTime = mode === 'dateTime' || mode === 'time' + const { locale } = useLocalization() + const { is24HourFormat: contextIs24HourFormat } = useDateTimeFormat() + const [value, setValue] = useControlledState({ + value: controlledValue, + onValueChange: onValueChange, + defaultValue: initialValue, + }) + const resolvedIs24HourFormat = is24HourFormat ?? contextIs24HourFormat + const timeLabel = new Intl.DateTimeFormat(locale, { + hour: '2-digit', + minute: '2-digit', + second: precision === 'second' || precision === 'millisecond' ? '2-digit' : undefined, + hourCycle: resolvedIs24HourFormat ? 'h23' : 'h12', + }).format(value) + + const timePicker = ( + + ) + + let content: ReactNode + if (useDate && useTime) { + content = ( + + ) + } else if (useDate) { + content = ( + + ) + } else { + content = timePicker + } + + return ( +
        + {content} +
        + ) +} diff --git a/packages/hightide-web/src/components/data-input/date/DateTimePickerDialog.tsx b/packages/hightide-web/src/components/data-input/date/DateTimePickerDialog.tsx new file mode 100644 index 00000000..16c49ed9 --- /dev/null +++ b/packages/hightide-web/src/components/data-input/date/DateTimePickerDialog.tsx @@ -0,0 +1,219 @@ +import { useControlledState } from '@helpwave/hightide-utils/hooks' +import { useHightideTranslation } from '@helpwave/hightide-utils/context/translation' +import { ArrowLeft, Trash } from 'lucide-react' +import type { HTMLAttributes } from 'react' +import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react' + +import { Visibility } from '../../layout/Visibility' +import { Button } from '../../interaction/Button' +import { IconButton } from '../../interaction/IconButton' +import { DateWheelPicker } from './DateWheelPicker' +import { type DateTimeWheelPickerProps } from './DateTimeWheelPicker' +import { TimeWheelPicker } from './TimeWheelPicker' +import type { InputComponentInterface } from '../input/TextInput' +import type { DateTimeFormat, Weekday } from '@helpwave/hightide-utils/utils' +import clsx from 'clsx' + +export interface DateTimePickerDialogProps extends +HTMLAttributes, +InputComponentInterface, +Pick +{ + allowRemove?: boolean, + weekStart?: Weekday, + markToday?: boolean, + pickerProps?: Omit, + mode?: DateTimeFormat, + label?: ReactNode, + labelId?: string, +} + +type DialogStep = 'date' | 'time' + +export const DateTimePickerDialog = ({ + initialValue = null, + value, + allowRemove = true, + onValueUpdate: onValueChange, + onValueCommit: onEditComplete, + mode = 'date', + pickerProps = {}, + start, + end, + weekStart: _weekStart, + markToday: _markToday, + isLooping, + loopingBehaviour, + is24HourFormat, + minuteIncrement, + secondIncrement, + millisecondIncrement, + precision, + labelId, + label, + ...props +}: DateTimePickerDialogProps) => { + const translation = useHightideTranslation() + const [state, setState] = useControlledState({ + value, + onValueChange, + defaultValue: initialValue + }) + const [pickerState, setPickerState] = useState(state ?? new Date()) + const [step, setStep] = useState('date') + const [compact, setCompact] = useState(false) + const rootRef = useRef(null) + const pairRef = useRef(null) + const compactRef = useRef(false) + const neededWidthRef = useRef(0) + + useEffect(() => { + setPickerState(state ?? new Date()) + }, [state]) + + useLayoutEffect(() => { + if (mode !== 'dateTime') { + compactRef.current = false + setCompact(false) + return + } + const root = rootRef.current + const pair = pairRef.current + if (!root || !pair) { + return + } + + const availableWidth = () => { + const style = getComputedStyle(root) + return root.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight) + } + + const update = (available: number) => { + if (!compactRef.current) { + neededWidthRef.current = pair.scrollWidth + } + const next = neededWidthRef.current > 0 && available < neededWidthRef.current + if (next === compactRef.current) { + return + } + compactRef.current = next + setCompact(next) + if (next) { + setStep('date') + } + } + + update(availableWidth()) + const observer = new ResizeObserver((entries) => { + const width = entries[0]?.contentRect.width + update(width ?? availableWidth()) + }) + observer.observe(root) + return () => observer.disconnect() + }, [mode, compact]) + + const splitSteps = mode === 'dateTime' && compact + const showDate = mode !== 'time' && (!splitSteps || step === 'date') + const showTime = mode !== 'date' && (!splitSteps || step === 'time') + + const clearValue = () => { + setState(null) + onEditComplete?.(null) + } + + const finish = () => { + if (splitSteps && step === 'date') { + setStep('time') + return + } + onEditComplete?.(pickerState) + } + + return ( +
        +
        + + {label ?? translation('sDateTimeSelect', { datetimeMode: mode })} + + + + +
        +
        + {showDate && ( + + )} + {showTime && ( + + )} +
        +
        + {splitSteps ? ( + setStep('date')} + /> + ) : ( + + + + )} + +
        +
        + ) +} diff --git a/packages/hightide-web/src/components/data-input/date/DateTimeWheelPicker.tsx b/packages/hightide-web/src/components/data-input/date/DateTimeWheelPicker.tsx new file mode 100644 index 00000000..b59eb161 --- /dev/null +++ b/packages/hightide-web/src/components/data-input/date/DateTimeWheelPicker.tsx @@ -0,0 +1,70 @@ +import clsx from 'clsx' +import { useControlledState } from '@helpwave/hightide-utils/hooks' + +import type { InputComponentInterface } from '../input/TextInput' +import type { DateWheelPickerProps } from './DateWheelPicker' +import { DateWheelPicker } from './DateWheelPicker' +import type { TimeWheelPickerProps } from './TimeWheelPicker' +import { TimeWheelPicker } from './TimeWheelPicker' + +export type DateTimeWheelPickerProps = + InputComponentInterface & + Pick & + Pick & { + className?: string, + dateWheelPickerProps?: Omit, + timeWheelPickerProps?: Omit, + } + +export const DateTimeWheelPicker = ({ + value: controlledValue, + initialValue = new Date(), + onValueUpdate: onValueChange, + onValueCommit: onEditComplete, + start, + end, + isLooping, + loopingBehaviour, + is24HourFormat, + minuteIncrement, + secondIncrement, + millisecondIncrement, + precision, + className, + dateWheelPickerProps, + timeWheelPickerProps, +}: DateTimeWheelPickerProps) => { + const [value, setValue] = useControlledState({ + value: controlledValue, + onValueChange, + defaultValue: initialValue, + }) + + return ( +
        + + +
        + ) +} diff --git a/packages/hightide-web/src/components/data-input/date/DateWheelPicker.tsx b/packages/hightide-web/src/components/data-input/date/DateWheelPicker.tsx new file mode 100644 index 00000000..312665e9 --- /dev/null +++ b/packages/hightide-web/src/components/data-input/date/DateWheelPicker.tsx @@ -0,0 +1,159 @@ +import { useCallback, useMemo, useRef } from 'react' +import clsx from 'clsx' +import { DateUtils, range } from '@helpwave/hightide-utils/utils' +import { useControlledState, useStableEvent } from '@helpwave/hightide-utils/hooks' + +import { useLocalization } from '../../../global-contexts/localization/forward-exports' +import type { InputComponentInterface } from '../input/TextInput' +import { WheelPicker } from '../../interaction/WheelPicker' +import type { WheelLoopingBehaviour } from './TimeWheelPicker' + +const defaultStart = DateUtils.subtractDuration(new Date(), { years: 100 }) +const defaultEnd = DateUtils.addDuration(new Date(), { years: 100 }) + +function monthsForYear(year: number, minTimestamp?: number, maxTimestamp?: number) { + return range(12).filter((month) => { + const monthStart = new Date(year, month, 1).getTime() + const monthEnd = new Date(year, month + 1, 0).getTime() + const isAfterStart = minTimestamp === undefined || monthEnd >= minTimestamp + const isBeforeEnd = maxTimestamp === undefined || monthStart <= maxTimestamp + return isAfterStart && isBeforeEnd + }) +} + +function daysInMonth(year: number, month: number) { + return new Date(year, month + 1, 0).getDate() +} + +function withDateParts(date: Date, year: number, month: number, day: number) { + const next = new Date(date) + const lastDay = daysInMonth(year, month) + next.setFullYear(year, month, Math.min(day, lastDay)) + return next +} + +export type DateWheelPickerProps = InputComponentInterface & { + start?: Date, + end?: Date, + isLooping?: boolean, + loopingBehaviour?: WheelLoopingBehaviour, + className?: string, +} + +export const DateWheelPicker = ({ + value: controlledValue, + initialValue = new Date(), + start = defaultStart, + end = defaultEnd, + onValueUpdate: onValueChange, + onValueCommit: onEditComplete, + isLooping = true, + loopingBehaviour = 'update', + className, +}: DateWheelPickerProps) => { + const { locale } = useLocalization() + const [value, setValue] = useControlledState({ + value: controlledValue, + onValueChange, + defaultValue: initialValue, + }) + const valueRef = useRef(value) + valueRef.current = value + const onEditCompleteStable = useStableEvent(onEditComplete) + + const monthNames = useMemo(() => { + const formatter = new Intl.DateTimeFormat(locale, { month: 'short' }) + return Array.from({ length: 12 }, (_, index) => formatter.format(new Date(2000, index, 1))) + }, [locale]) + + const years = useMemo(() => + range([start.getFullYear(), end.getFullYear()], { exclusiveEnd: false }), + [end, start]) + + const minTimestamp = useMemo(() => new Date(start.getFullYear(), start.getMonth(), 1).getTime(), [start]) + const maxTimestamp = useMemo(() => new Date(end.getFullYear(), end.getMonth() + 1, 0).getTime(), [end]) + const months = useMemo( + () => monthsForYear(value.getFullYear(), minTimestamp, maxTimestamp), + [maxTimestamp, minTimestamp, value] + ) + const days = useMemo(() => { + const count = daysInMonth(value.getFullYear(), value.getMonth()) + return range(count).map((day) => day + 1).filter((day) => { + const timestamp = new Date(value.getFullYear(), value.getMonth(), day).getTime() + return timestamp >= minTimestamp && timestamp <= maxTimestamp + }) + }, [maxTimestamp, minTimestamp, value]) + + const commit = useCallback((next: Date) => { + valueRef.current = next + setValue(next) + onEditCompleteStable(next) + }, [onEditCompleteStable, setValue]) + + const selectDay = useCallback((day: number) => { + commit(withDateParts(valueRef.current, valueRef.current.getFullYear(), valueRef.current.getMonth(), day)) + }, [commit]) + + const selectMonth = useCallback((month: number) => { + const current = valueRef.current + commit(withDateParts(current, current.getFullYear(), month, current.getDate())) + }, [commit]) + + const selectYear = useCallback((year: number) => { + const current = valueRef.current + const availableMonths = monthsForYear(year, minTimestamp, maxTimestamp) + const month = availableMonths.includes(current.getMonth()) + ? current.getMonth() + : availableMonths[0] ?? 0 + commit(withDateParts(current, year, month, current.getDate())) + }, [commit, maxTimestamp, minTimestamp]) + + const loopDay = useCallback((direction: 1 | -1) => { + if (loopingBehaviour === 'ignore') { + return + } + const current = valueRef.current + const next = new Date(current) + if (direction === 1) { + next.setDate(1) + next.setMonth(next.getMonth() + 1) + } else { + next.setDate(0) + } + commit(next) + }, [commit, loopingBehaviour]) + + const loopMonth = useCallback((direction: 1 | -1) => { + if (loopingBehaviour === 'ignore') { + return + } + const current = valueRef.current + commit(withDateParts(current, current.getFullYear() + direction, current.getMonth(), current.getDate())) + }, [commit, loopingBehaviour]) + + return ( +
        + loopDay(direction)} isLooping={isLooping}> + {days.map((day) => ( + + {String(day).padStart(2, '0')} + + ))} + + loopMonth(direction)} isLooping={isLooping}> + {months.map((month) => ( + + {monthNames[month]} + + ))} + + + {years.map((year) => ( + + {year} + + ))} + +
        + ) +} diff --git a/packages/hightide-web/src/components/user-interaction/date/TimePicker.tsx b/packages/hightide-web/src/components/data-input/date/TimePicker.tsx similarity index 92% rename from packages/hightide-web/src/components/user-interaction/date/TimePicker.tsx rename to packages/hightide-web/src/components/data-input/date/TimePicker.tsx index 8eb8a72b..d1b97272 100644 --- a/packages/hightide-web/src/components/user-interaction/date/TimePicker.tsx +++ b/packages/hightide-web/src/components/data-input/date/TimePicker.tsx @@ -1,7 +1,8 @@ +import clsx from 'clsx' import { useEffect, useMemo, useRef } from 'react' import { closestMatch, range } from '@helpwave/hightide-utils/utils' -import { Button } from '../Button' -import type { FormFieldDataHandling } from '../../form/FormField' +import { Button } from '../../interaction/Button' +import type { InputComponentInterface } from '../input/TextInput' import { useControlledState } from '@helpwave/hightide-utils/hooks' import type { DateTimePrecision } from '@helpwave/hightide-utils/utils' import { Visibility } from '../../layout/Visibility' @@ -13,8 +14,7 @@ export type TimePickerSecondIncrement = '1s' | '5s' | '10s' | '15s' | '30s' export type TimePickerMillisecondIncrement = '1ms' | '5ms' | '10ms' | '25ms' | '50ms' | '100ms' | '250ms' | '500ms' -export interface TimePickerProps extends Partial> { - initialValue?: Date, +export interface TimePickerProps extends InputComponentInterface { is24HourFormat?: boolean, precision?: DateTimePrecision, minuteIncrement?: TimePickerMinuteIncrement, @@ -26,8 +26,8 @@ export interface TimePickerProps extends Partial> { export const TimePicker = ({ value: controlledValue, initialValue = new Date(), - onValueChange, - onEditComplete, + onValueUpdate: onValueChange, + onValueCommit: onEditComplete, is24HourFormat: is24HourFormatOverride, minuteIncrement = '5min', secondIncrement = '5s', @@ -50,6 +50,8 @@ export const TimePicker = ({ const minutes = useMemo(() => { const full = range(60) switch (minuteIncrement) { + case '1min': + return full case '5min': return full.filter(value => value % 5 === 0) case '10min': @@ -126,8 +128,8 @@ export const TimePicker = ({ } return ( -
        -
        +
        +
        {hours.map(hour => { const isSelected = hour === value.getHours() - (!is24HourFormat && isPM ? 12 : 0) return ( @@ -144,7 +146,7 @@ export const TimePicker = ({ ) })}
        -
        +
        {minutes.map(minute => { const isSelected = minute === closestMinute return ( @@ -162,7 +164,7 @@ export const TimePicker = ({ })}
        -
        +
        {seconds.map(second => { const isSelected = second === closestSecond return ( @@ -181,7 +183,7 @@ export const TimePicker = ({
        -
        +
        {milliseconds.map(millisecond => { const isSelected = millisecond === closestMillisecond return ( @@ -200,7 +202,7 @@ export const TimePicker = ({
        {!is24HourFormat && ( -
        +
        ) }) diff --git a/packages/hightide-web/src/components/user-interaction/Menu.tsx b/packages/hightide-web/src/components/interaction/Menu.tsx similarity index 95% rename from packages/hightide-web/src/components/user-interaction/Menu.tsx rename to packages/hightide-web/src/components/interaction/Menu.tsx index 6f484eba..05378da4 100644 --- a/packages/hightide-web/src/components/user-interaction/Menu.tsx +++ b/packages/hightide-web/src/components/interaction/Menu.tsx @@ -1,8 +1,8 @@ import { type PropsWithChildren, type ReactNode, useCallback, useRef, useState } from 'react' import clsx from 'clsx' import { BagFunctionUtil } from '@helpwave/hightide-utils/utils' -import type { PopUpProps } from '../layout/popup/PopUp' -import { PopUp } from '../layout/popup/PopUp' +import type { PopUpProps } from '../layout/PopUp/PopUp' +import { PopUp } from '../layout/PopUp/PopUp' export type MenuItemProps = { onClick?: () => void, diff --git a/packages/hightide-web/src/components/interaction/Pressable.tsx b/packages/hightide-web/src/components/interaction/Pressable.tsx new file mode 100644 index 00000000..055afbad --- /dev/null +++ b/packages/hightide-web/src/components/interaction/Pressable.tsx @@ -0,0 +1,121 @@ +import clsx from 'clsx' +import type { ButtonHTMLAttributes } from 'react' +import { forwardRef } from 'react' + +import { LoadingSpinner } from '../visualization/LoadingSpinner' +import type { ColoringColor, ColoringColorVariant, ColoringStyle } from '../../utils/coloring' +import { ColoringUtils } from '../../utils/coloring' + +export type { ColoringColor, ColoringColorVariant, ColoringMode, ColoringStyle } from '../../utils/coloring' +export { ColoringUtils } from '../../utils/coloring' + +export type ButtonSize = 'xs' | 'sm' | 'md' | 'lg' | null + +export const buttonVariants = ['elevated', 'filled', 'tonal', 'outlined', 'foreground'] as const + +export type ButtonVariant = typeof buttonVariants[number] + +export const iconButtonVariants = ['elevated', 'filled', 'tonal', 'foreground'] as const + +export type IconButtonVariant = typeof iconButtonVariants[number] + +const buttonColorsList = ['primary', 'secondary', 'positive', 'warning', 'negative', 'neutral', 'surfaceInverse'] as const + +export type ButtonColor = typeof buttonColorsList[number] | null + +export function coloringColorName(color: ButtonColor): ColoringColor | undefined { + if (color === 'surfaceInverse') { + return 'surface-inverse' + } + return color ?? undefined +} + +export function buttonColorForVariant(variant: ButtonVariant | IconButtonVariant, color: ButtonColor) { + if (variant === 'foreground' && color === 'neutral') { + return 'surfaceInverse' as const + } + return color +} + +export const ButtonUtil = { + colors: buttonColorsList, + variants: buttonVariants, +} + +export const mapButtonVariant = (variant: ButtonVariant) => { + switch (variant) { + case 'elevated': + return { coloringStyle: 'filled', colorVariant: 'normal', bordered: false, elevated: true } as const + case 'filled': + return { coloringStyle: 'filled', colorVariant: 'normal', bordered: false, elevated: false } as const + case 'tonal': + return { coloringStyle: 'filled', colorVariant: 'tonal', bordered: false, elevated: false } as const + case 'outlined': + return { coloringStyle: 'foreground', colorVariant: 'normal', bordered: true, elevated: false } as const + case 'foreground': + return { coloringStyle: 'foreground', colorVariant: 'normal', bordered: false, elevated: false } as const + } +} + +export type PressableProps = ButtonHTMLAttributes & { + size?: ButtonSize, + color?: ButtonColor, + coloringStyle?: ColoringStyle, + colorVariant?: ColoringColorVariant, + bordered?: boolean, + elevated?: boolean, + isProcessing?: boolean, + processingIndicator?: boolean, +} + +export const Pressable = forwardRef(function Pressable({ + children, + size = 'md', + color = 'primary', + coloringStyle = 'filled', + colorVariant = 'normal', + bordered = false, + elevated = false, + disabled, + isProcessing = false, + processingIndicator = true, + className, + type, + onClick, + ...props +}, ref) { + return ( + + ) +}) diff --git a/packages/hightide-web/src/components/user-interaction/Tooltip.tsx b/packages/hightide-web/src/components/interaction/Tooltip.tsx similarity index 96% rename from packages/hightide-web/src/components/user-interaction/Tooltip.tsx rename to packages/hightide-web/src/components/interaction/Tooltip.tsx index 8f06ed65..b81c52b4 100644 --- a/packages/hightide-web/src/components/user-interaction/Tooltip.tsx +++ b/packages/hightide-web/src/components/interaction/Tooltip.tsx @@ -1,3 +1,4 @@ +import clsx from 'clsx' import type { PropsWithChildren, ReactNode, RefObject } from 'react' import { forwardRef, useContext, useEffect } from 'react' import { useId } from 'react' @@ -7,7 +8,7 @@ import { useOverlayRegistry } from '@helpwave/hightide-utils/hooks' import { useTransitionState } from '../../hooks/useTransitionState' import type { TooltipConfig } from '../../global-contexts/hightide-config/HightideConfigContext' import { useHightideConfig } from '../../global-contexts/hightide-config/HightideConfigContext' -import { Portal } from '../utils/Portal' +import { Portal } from '../layout/Portal' import type { AnchoredFloatingContainerProps } from '../layout/AnchoredFloatingContainer' import { AnchoredFloatingContainer } from '../layout/AnchoredFloatingContainer' import { createContext } from 'react' @@ -163,20 +164,14 @@ export const TooltipRoot = ({ } - - - - - type TooltipAligment = 'top' | 'bottom' | 'left' | 'right' export interface TooltipDisplayProps extends Omit, Partial { - 'alignment'?: TooltipAligment, - 'disabled'?: boolean, - 'anchor'?: RefObject, - 'isShown'?: boolean, - 'options'?: Omit, - 'data-name'?: string, + alignment?: TooltipAligment, + disabled?: boolean, + anchor?: RefObject, + isShown?: boolean, + options?: Omit, } export const TooltipDisplay = forwardRef(function TooltipAnchoredFloatingContainer({ @@ -242,7 +237,7 @@ export const TooltipDisplay = forwardRef(fu ...props.options, }} - data-name={props['data-name'] ?? 'tooltip'} + className={clsx('tooltip', props.className)} data-state={transitionState} data-animated={isAnimated ? '': undefined} @@ -253,8 +248,7 @@ export const TooltipDisplay = forwardRef(fu position: 'fixed', opacity: isVisible ? undefined : 0, ...props.style - }} - > + }}> {children} diff --git a/packages/hightide-web/src/components/interaction/WheelPicker/WheelPicker.tsx b/packages/hightide-web/src/components/interaction/WheelPicker/WheelPicker.tsx new file mode 100644 index 00000000..9306473a --- /dev/null +++ b/packages/hightide-web/src/components/interaction/WheelPicker/WheelPicker.tsx @@ -0,0 +1,48 @@ +import type { ReactElement, ReactNode } from 'react' +import clsx from 'clsx' + +import type { WheelPickerBarProps } from './WheelPickerBar' +import { WheelPickerBar } from './WheelPickerBar' +import { WheelPickerContext } from './WheelPickerContext' +import type { WheelPickerOptionProps } from './WheelPickerOption' +import { WheelPickerOption } from './WheelPickerOption' +import type { WheelPickerLoopEvent, WheelPickerRootProps } from './WheelPickerRoot' +import { WheelPickerRoot } from './WheelPickerRoot' + +export type WheelPickerProps = Omit, 'children'> & { + children?: ReactNode, + rootProps?: Omit, 'children'>, + barProps?: WheelPickerBarProps, +} + +type WheelPickerComponent = (props: WheelPickerProps) => ReactElement | null + +function WheelPickerComponentImpl({ + children, + className, + rootProps, + barProps, + ...props +}: WheelPickerProps) { + return ( + + {children} + + + ) +} + +const WheelPicker = Object.assign(WheelPickerComponentImpl as WheelPickerComponent, { + Root: WheelPickerRoot, + Option: WheelPickerOption, + Bar: WheelPickerBar, + Context: WheelPickerContext, + Consumer: WheelPickerContext.Consumer, +}) + +export { WheelPicker } +export type { WheelPickerLoopEvent, WheelPickerOptionProps, WheelPickerRootProps, WheelPickerBarProps } diff --git a/packages/hightide-web/src/components/interaction/WheelPicker/WheelPickerBar.tsx b/packages/hightide-web/src/components/interaction/WheelPicker/WheelPickerBar.tsx new file mode 100644 index 00000000..c6835cee --- /dev/null +++ b/packages/hightide-web/src/components/interaction/WheelPicker/WheelPickerBar.tsx @@ -0,0 +1,17 @@ +import type { HTMLAttributes } from 'react' +import clsx from 'clsx' + +export type WheelPickerBarProps = HTMLAttributes + +export function WheelPickerBar({ className, ...props }: WheelPickerBarProps) { + return ( +
        +
        +
        +
        + ) +} diff --git a/packages/hightide-web/src/components/interaction/WheelPicker/WheelPickerContext.tsx b/packages/hightide-web/src/components/interaction/WheelPicker/WheelPickerContext.tsx new file mode 100644 index 00000000..335398d0 --- /dev/null +++ b/packages/hightide-web/src/components/interaction/WheelPicker/WheelPickerContext.tsx @@ -0,0 +1,39 @@ +import type { ReactNode } from 'react' +import { createContext, useContext } from 'react' + +export type WheelPickerRegisteredOption = { + id: string, + value: T, + element: HTMLDivElement, +} + +export type WheelPickerContextValue = { + value: T | undefined, + disabled: boolean, + listboxId: string, + registerOption: (option: WheelPickerRegisteredOption) => () => void, + selectValue: (value: T) => void, +} + +export const WheelPickerContext = createContext | null>(null) + +export function useWheelPickerContext(): WheelPickerContextValue { + const context = useContext(WheelPickerContext) + if (!context) { + throw new Error('WheelPicker components must be used within a WheelPicker.Root') + } + return context as WheelPickerContextValue +} + +export type WheelPickerContextProviderProps = { + value: WheelPickerContextValue, + children: ReactNode, +} + +export function WheelPickerContextProvider({ value, children }: WheelPickerContextProviderProps) { + return ( + }> + {children} + + ) +} diff --git a/packages/hightide-web/src/components/interaction/WheelPicker/WheelPickerOption.tsx b/packages/hightide-web/src/components/interaction/WheelPicker/WheelPickerOption.tsx new file mode 100644 index 00000000..c766b720 --- /dev/null +++ b/packages/hightide-web/src/components/interaction/WheelPicker/WheelPickerOption.tsx @@ -0,0 +1,89 @@ +import type { HTMLAttributes, ReactElement, ReactNode } from 'react' +import { createContext, useContext, useLayoutEffect, useMemo, useRef } from 'react' +import clsx from 'clsx' + +import { useWheelPickerContext } from './WheelPickerContext' + +const WheelPickerPreviewContext = createContext(false) + +export function WheelPickerPreviewScope({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} + +export type WheelPickerOptionProps = ( + T extends string + ? { + valueId?: undefined, + value: T, + } + : { + valueId: string, + value: T, + } +) & Omit, 'value' | 'valueId'> & { + children?: ReactNode, +} + +type WheelPickerOptionComponent = (props: WheelPickerOptionProps) => ReactElement | null + +function toIdentity(value: T, valueId: string | undefined) { + if (valueId === undefined) { + return { value, id: value as string } + } + return { value, id: valueId } +} + +function WheelPickerOptionImpl({ + value, + valueId, + className, + children, + onClick, + ...props +}: WheelPickerOptionProps) { + const isPreview = useContext(WheelPickerPreviewContext) + const { disabled: rootDisabled, value: selected, registerOption, selectValue } = useWheelPickerContext() + const ref = useRef(null) + const identity = useMemo(() => toIdentity(value, valueId), [value, valueId]) + const isSelected = !isPreview && Object.is(selected, value) + + useLayoutEffect(() => { + if (isPreview) { + return + } + const element = ref.current + if (!element) { + return + } + return registerOption({ id: identity.id, value, element }) + }, [identity, isPreview, registerOption, value]) + + return ( +
        { + onClick?.(event) + if (event.defaultPrevented || rootDisabled) { + return + } + selectValue(value) + }} + > + {children ?? (valueId === undefined ? value as string : null)} +
        + ) +} + +const WheelPickerOption = WheelPickerOptionImpl as WheelPickerOptionComponent + +export { WheelPickerOption } diff --git a/packages/hightide-web/src/components/interaction/WheelPicker/WheelPickerRoot.tsx b/packages/hightide-web/src/components/interaction/WheelPicker/WheelPickerRoot.tsx new file mode 100644 index 00000000..ed571ddc --- /dev/null +++ b/packages/hightide-web/src/components/interaction/WheelPicker/WheelPickerRoot.tsx @@ -0,0 +1,465 @@ +import type { CSSProperties, HTMLAttributes, ReactElement, ReactNode } from 'react' +import { Children, cloneElement, isValidElement, useCallback, useId, useLayoutEffect, useMemo, useRef, useState } from 'react' +import clsx from 'clsx' + +import type { WheelPickerRegisteredOption } from './WheelPickerContext' +import { WheelPickerContextProvider } from './WheelPickerContext' +import { WheelPickerBar } from './WheelPickerBar' +import { WheelPickerPreviewScope } from './WheelPickerOption' + +export type WheelPickerLoopEvent = { + value: T, + direction: 1 | -1, + loopedAround: 'start' | 'end', +} + +export type WheelPickerRootProps = Omit, 'defaultValue' | 'children' | 'onChange'> & { + value?: T, + defaultValue?: T, + onValueChange?: (value: T) => void, + onLoop?: (event: WheelPickerLoopEvent) => void, + disabled?: boolean, + isLooping?: boolean, + visibleRows?: number, + children?: ReactNode, +} + +function wrappedNodes(nodes: ReactNode[], start: number, count: number) { + if (nodes.length === 0 || count <= 0) { + return [] + } + return Array.from({ length: count }, (_, offset) => { + const index = ((start + offset) % nodes.length + nodes.length) % nodes.length + return nodes[index] + }) +} + +function previewCopies(nodes: ReactNode[], prefix: string) { + return nodes.map((node, index) => { + if (!isValidElement(node)) { + return node + } + return cloneElement(node, { key: `${prefix}-${index}` }) + }) +} + +function isWheelPickerBar(node: ReactNode): node is ReactElement { + return isValidElement(node) && node.type === WheelPickerBar +} + +function orderedOptions(options: WheelPickerRegisteredOption[]) { + return options.slice().sort((left, right) => { + if (left.element === right.element) { + return 0 + } + const position = left.element.compareDocumentPosition(right.element) + return position & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1 + }) +} + +function itemHeightOf(options: WheelPickerRegisteredOption[]) { + return options[0]?.element.offsetHeight ?? 0 +} + +function resolveStep(currentIndex: number, steps: number, length: number, isLooping: boolean) { + const raw = currentIndex + steps + if (!isLooping || length <= 0) { + return { + index: Math.max(0, Math.min(length - 1, raw)), + loops: 0, + } + } + return { + index: ((raw % length) + length) % length, + loops: Math.floor(raw / length), + } +} + +function paintOptions(options: WheelPickerRegisteredOption[], scrollTop: number) { + const itemHeight = itemHeightOf(options) + if (itemHeight <= 0) { + return + } + const centeredIndex = Math.round(scrollTop / itemHeight) + options.forEach((option, index) => { + option.element.toggleAttribute('data-centered', index === centeredIndex) + }) +} + +export function WheelPickerRoot({ + value, + defaultValue, + onValueChange, + onLoop, + disabled = false, + isLooping = false, + visibleRows = 1, + className, + children, + onKeyDown, + style, + ...props +}: WheelPickerRootProps) { + const listboxId = useId() + const scrollerRef = useRef(null) + const optionsRef = useRef[]>([]) + const userScrollingRef = useRef(false) + const onValueChangeRef = useRef(onValueChange) + const onLoopRef = useRef(onLoop) + const isLoopingRef = useRef(isLooping) + const [uncontrolled, setUncontrolled] = useState(defaultValue) + const [options, setOptions] = useState[]>([]) + const optionsPublishScheduled = useRef(false) + const selected = value !== undefined ? value : uncontrolled + const selectedRef = useRef(selected) + selectedRef.current = selected + onValueChangeRef.current = onValueChange + onLoopRef.current = onLoop + isLoopingRef.current = isLooping + + const childList = Children.toArray(children) + const bars = childList.filter(isWheelPickerBar) + const items = childList.filter((child) => !isWheelPickerBar(child)) + + const applyValue = useCallback((next: T) => { + if (Object.is(selectedRef.current, next)) { + return + } + selectedRef.current = next + setUncontrolled(next) + onValueChangeRef.current?.(next) + }, []) + + const scrollToIndex = useCallback((index: number, behavior: ScrollBehavior) => { + const scroller = scrollerRef.current + const options = orderedOptions(optionsRef.current) + const itemHeight = itemHeightOf(options) + if (!scroller || itemHeight <= 0) { + return + } + const top = Math.max(0, Math.min(options.length - 1, index)) * itemHeight + if (Math.abs(scroller.scrollTop - top) <= 1) { + paintOptions(options, scroller.scrollTop) + return + } + userScrollingRef.current = behavior === 'smooth' + scroller.scrollTo({ top, behavior }) + if (behavior === 'auto') { + paintOptions(options, top) + } + }, []) + + const syncToSelected = useCallback((behavior: ScrollBehavior) => { + if (userScrollingRef.current || disabled) { + return + } + const options = orderedOptions(optionsRef.current) + if (options.length === 0) { + return + } + const selectedValue = selectedRef.current + const index = selectedValue === undefined + ? 0 + : options.findIndex((option) => Object.is(option.value, selectedValue)) + scrollToIndex(index < 0 ? 0 : index, behavior) + }, [disabled, scrollToIndex]) + + const commitFromScroll = useCallback(() => { + if (disabled) { + return + } + const scroller = scrollerRef.current + const options = orderedOptions(optionsRef.current) + const itemHeight = itemHeightOf(options) + if (!scroller || itemHeight <= 0 || options.length === 0) { + return + } + const index = Math.round(scroller.scrollTop / itemHeight) + const option = options[Math.max(0, Math.min(options.length - 1, index))] + if (!option) { + return + } + paintOptions(options, scroller.scrollTop) + applyValue(option.value) + }, [applyValue, disabled]) + + const stepBy = useCallback((steps: number) => { + const scroller = scrollerRef.current + const options = orderedOptions(optionsRef.current) + const itemHeight = itemHeightOf(options) + if (!scroller || itemHeight <= 0 || options.length === 0 || steps === 0) { + return + } + const currentIndex = Math.round(scroller.scrollTop / itemHeight) + const { index, loops } = resolveStep(currentIndex, steps, options.length, isLoopingRef.current) + const option = options[index] + if (!option) { + return + } + scrollToIndex(index, 'auto') + applyValue(option.value) + if (loops === 0) { + return + } + const direction = loops > 0 ? 1 : -1 + const loopedAround = direction === 1 ? 'end' : 'start' + for (let count = 0; count < Math.abs(loops); count += 1) { + onLoopRef.current?.({ + value: option.value, + direction, + loopedAround, + }) + } + }, [applyValue, scrollToIndex]) + + const selectValue = useCallback((next: T) => { + if (disabled) { + return + } + const options = orderedOptions(optionsRef.current) + const index = options.findIndex((option) => Object.is(option.value, next)) + if (index < 0) { + return + } + const currentIndex = options.findIndex((option) => Object.is(option.value, selectedRef.current)) + if (isLooping && currentIndex >= 0 && options.length > 1) { + const forward = (index - currentIndex + options.length) % options.length + const steps = forward <= options.length / 2 ? forward : forward - options.length + if (steps !== 0 && (currentIndex + steps < 0 || currentIndex + steps >= options.length)) { + stepBy(steps) + return + } + } + scrollToIndex(index, 'smooth') + applyValue(next) + }, [applyValue, disabled, isLooping, scrollToIndex, stepBy]) + + const publishOptions = useCallback(() => { + if (optionsPublishScheduled.current) { + return + } + optionsPublishScheduled.current = true + queueMicrotask(() => { + optionsPublishScheduled.current = false + setOptions(orderedOptions(optionsRef.current)) + }) + }, []) + + const registerOption = useCallback((option: WheelPickerRegisteredOption) => { + optionsRef.current = optionsRef.current.filter((entry) => entry.id !== option.id) + optionsRef.current.push(option) + publishOptions() + return () => { + optionsRef.current = optionsRef.current.filter((entry) => entry.element !== option.element) + publishOptions() + } + }, [publishOptions]) + + useLayoutEffect(() => { + syncToSelected('auto') + }, [selected, syncToSelected]) + + useLayoutEffect(() => { + const scroller = scrollerRef.current + if (!scroller) { + return + } + let timer = 0 + let wheelRemainder = 0 + const onWheel = (event: WheelEvent) => { + event.preventDefault() + if (disabled) { + return + } + const options = orderedOptions(optionsRef.current) + const itemHeight = itemHeightOf(options) + if (!itemHeight || options.length === 0) { + return + } + const pixelDelta = event.deltaMode === WheelEvent.DOM_DELTA_PIXEL + ? event.deltaY + : Math.sign(event.deltaY) * itemHeight + const delta = Math.abs(pixelDelta) >= itemHeight + ? Math.sign(pixelDelta) * itemHeight + : pixelDelta + wheelRemainder += delta + const steps = Math.trunc(wheelRemainder / itemHeight) + if (steps === 0) { + return + } + wheelRemainder -= steps * itemHeight + stepBy(steps) + } + const onScroll = () => { + if (disabled) { + return + } + userScrollingRef.current = true + paintOptions(orderedOptions(optionsRef.current), scroller.scrollTop) + window.clearTimeout(timer) + timer = window.setTimeout(() => { + userScrollingRef.current = false + commitFromScroll() + }, 80) + } + const onScrollEnd = () => { + window.clearTimeout(timer) + userScrollingRef.current = false + commitFromScroll() + } + let touchStartY = 0 + let touchStartScroll = 0 + const onTouchStart = (event: TouchEvent) => { + touchStartY = event.touches[0]?.clientY ?? 0 + touchStartScroll = scroller.scrollTop + } + const onTouchEnd = (event: TouchEvent) => { + if (!isLoopingRef.current) { + return + } + const options = orderedOptions(optionsRef.current) + const itemHeight = itemHeightOf(options) + if (itemHeight <= 0 || options.length === 0) { + return + } + const endY = event.changedTouches[0]?.clientY ?? touchStartY + const dragged = touchStartY - endY + const maxScroll = (options.length - 1) * itemHeight + const startedAtEnd = touchStartScroll >= maxScroll - 1 + const startedAtStart = touchStartScroll <= 1 + if (startedAtEnd && dragged > itemHeight / 2) { + stepBy(1) + } else if (startedAtStart && dragged < -itemHeight / 2) { + stepBy(-1) + } + } + const root = scroller.parentElement ?? scroller + root.addEventListener('wheel', onWheel, { passive: false }) + root.addEventListener('touchstart', onTouchStart, { passive: true }) + root.addEventListener('touchend', onTouchEnd) + scroller.addEventListener('scroll', onScroll, { passive: true }) + scroller.addEventListener('scrollend', onScrollEnd) + return () => { + window.clearTimeout(timer) + root.removeEventListener('wheel', onWheel) + root.removeEventListener('touchstart', onTouchStart) + root.removeEventListener('touchend', onTouchEnd) + scroller.removeEventListener('scroll', onScroll) + scroller.removeEventListener('scrollend', onScrollEnd) + } + }, [commitFromScroll, disabled, scrollToIndex, stepBy]) + + const moveSelection = useCallback((direction: 1 | -1 | 'start' | 'end') => { + const options = orderedOptions(optionsRef.current) + if (options.length === 0) { + return + } + const currentIndex = options.findIndex((option) => Object.is(option.value, selectedRef.current)) + let nextIndex = currentIndex < 0 ? (direction === -1 ? options.length : -1) : currentIndex + if (direction === 'start') { + nextIndex = 0 + } else if (direction === 'end') { + nextIndex = options.length - 1 + } else { + const { index, loops } = resolveStep(currentIndex < 0 ? 0 : currentIndex, direction, options.length, isLooping) + nextIndex = index + const option = options[nextIndex] + if (!option) { + return + } + scrollToIndex(nextIndex, 'smooth') + applyValue(option.value) + if (loops !== 0) { + onLoop?.({ + value: option.value, + direction, + loopedAround: direction === 1 ? 'end' : 'start', + }) + } + return + } + const option = options[nextIndex] + if (!option) { + return + } + scrollToIndex(nextIndex, 'smooth') + applyValue(option.value) + }, [applyValue, isLooping, onLoop, scrollToIndex]) + + const contextValue = useMemo(() => ({ + value: selected, + disabled, + listboxId, + registerOption, + selectValue, + }), [disabled, listboxId, registerOption, selectValue, selected]) + + const activeId = options.find((option) => Object.is(option.value, selected))?.id + const rowSpan = Number.isFinite(visibleRows) ? Math.max(0, visibleRows) : 1 + const previewCount = isLooping ? Math.ceil(rowSpan) : 0 + const leadingPreview = previewCopies(wrappedNodes(items, items.length - previewCount, previewCount), 'leading') + const trailingPreview = previewCopies(wrappedNodes(items, 0, previewCount), 'trailing') + const pickerStyle = { + ...style, + '--wheel-picker-visible-span': rowSpan, + } as CSSProperties + + return ( + +
        { + onKeyDown?.(event) + if (event.defaultPrevented || disabled) { + return + } + if (event.key === 'ArrowDown') { + event.preventDefault() + moveSelection(1) + } else if (event.key === 'ArrowUp') { + event.preventDefault() + moveSelection(-1) + } else if (event.key === 'Home') { + event.preventDefault() + moveSelection('start') + } else if (event.key === 'End') { + event.preventDefault() + moveSelection('end') + } + }} + > +
        + {isLooping && ( +
        + + {leadingPreview} + +
        + )} + {items} + {isLooping && ( +
        + + {trailingPreview} + +
        + )} +
        + {bars} +
        +
        + ) +} diff --git a/packages/hightide-web/src/components/interaction/WheelPicker/index.ts b/packages/hightide-web/src/components/interaction/WheelPicker/index.ts new file mode 100644 index 00000000..6d70b4b2 --- /dev/null +++ b/packages/hightide-web/src/components/interaction/WheelPicker/index.ts @@ -0,0 +1,7 @@ +export { WheelPicker } from './WheelPicker' +export { WheelPickerRoot } from './WheelPickerRoot' +export { WheelPickerOption } from './WheelPickerOption' +export { WheelPickerBar } from './WheelPickerBar' +export { useWheelPickerContext } from './WheelPickerContext' + +export type { WheelPickerProps, WheelPickerRootProps, WheelPickerOptionProps, WheelPickerBarProps, WheelPickerLoopEvent } from './WheelPicker' diff --git a/packages/hightide-web/src/components/user-interaction/data/FilterList.tsx b/packages/hightide-web/src/components/interaction/data/FilterList.tsx similarity index 57% rename from packages/hightide-web/src/components/user-interaction/data/FilterList.tsx rename to packages/hightide-web/src/components/interaction/data/FilterList.tsx index 7f627f95..e890951c 100644 --- a/packages/hightide-web/src/components/user-interaction/data/FilterList.tsx +++ b/packages/hightide-web/src/components/interaction/data/FilterList.tsx @@ -5,14 +5,14 @@ import { FilterValueUtils, useFilterValueTranslation } from './filter-function' import { DataTypeUtils, type DataType } from './data-types' import { useHightideTranslation } from '@helpwave/hightide-utils/context/translation' import { PlusIcon } from 'lucide-react' -import { PopUpRoot } from '../../layout/popup/PopUpRoot' -import { PopUp } from '../../layout/popup/PopUp' -import { PopUpOpener } from '../../layout/popup/PopUpOpener' +import { PopUpRoot } from '../../layout/PopUp/PopUpRoot' +import { PopUp } from '../../layout/PopUp/PopUp' +import { PopUpOpener } from '../../layout/PopUp/PopUpOpener' import { Button } from '../Button' +import { Pressable } from '../Pressable' import { FilterPopUp } from './FilterPopUp' -import { Combobox } from '../Combobox/Combobox' -import { ComboboxOption } from '../Combobox/ComboboxOption' -import { PopUpContext } from '../../layout/popup/PopUpContext' +import { Combobox } from '../../data-input/Combobox/Combobox' +import { PopUpContext } from '../../layout/PopUp/PopUpContext' import type { FilterOperator } from './FilterOperator' import { FilterOperatorUtils } from './FilterOperator' import type { ColumnFilter } from '@tanstack/react-table' @@ -79,39 +79,48 @@ export const FilterList = ({ value, onValueChange, availableItems }: FilterListP {({ toggleOpen, props }) => ( - )} - {({ setIsOpen }) => ( - { - const item = itemRecord[id] - if (!item) return - const newValue: IdentifierFilterValue = { - id: item.id, - value: { - dataType: item.dataType, - operator: FilterOperatorUtils.getDefaultOperator(item.dataType), - parameter: {} - }, - } - setEditState(newValue) - setIsOpen(false) - }} - > - {inactiveItems.map(item => ( - - {DataTypeUtils.toIcon(item.dataType)} - {item.label} - - ))} - - )} + {(context) => { + if(!context) return + const { setIsOpen } = context + return ( + { + const item = itemRecord[id] + if (!item) return + const newValue: IdentifierFilterValue = { + id: item.id, + value: { + dataType: item.dataType, + operator: FilterOperatorUtils.getDefaultOperator(item.dataType), + parameter: {} + }, + } + setEditState(newValue) + setIsOpen(false) + }} + > + {inactiveItems.map(item => ( + + {DataTypeUtils.toIcon(item.dataType)} + {item.label} + + ))} + + )}} @@ -138,7 +147,7 @@ export const FilterList = ({ value, onValueChange, availableItems }: FilterListP > {({ toggleOpen, props }) => ( - + )} - {({ isOpen, setIsOpen }) => item.popUpBuilder ? ( - item.popUpBuilder({ - value: editState?.id === columnFilter.id ? editState.value : columnFilter.value, - onValueChange: value => setEditState({ ...columnFilter, value }), - onRemove: () => { - onValueChange(value.filter(prevItem => prevItem.id !== columnFilter.id)) - setEditState(undefined) - }, - operatorOverrides: item.operatorOverrides, - dataType: item.dataType, - tags: item.tags, - name: item.label, - isOpen, - onClose: () => setIsOpen(false), - }) - ) : ( - { - setEditState({ ...columnFilter, value }) - }} - onRemove={() => { - onValueChange(value.filter(prevItem => prevItem.id !== columnFilter.id)) - setEditState(undefined) - }} - onClose={() => setIsOpen(false)} - /> - )} + {(context) => { + if(!context) return + const { isOpen, setIsOpen } = context + return item.popUpBuilder ? ( + item.popUpBuilder({ + value: editState?.id === columnFilter.id ? editState.value : columnFilter.value, + onValueChange: value => setEditState({ ...columnFilter, value }), + onRemove: () => { + onValueChange(value.filter(prevItem => prevItem.id !== columnFilter.id)) + setEditState(undefined) + }, + operatorOverrides: item.operatorOverrides, + dataType: item.dataType, + tags: item.tags, + name: item.label, + isOpen, + onClose: () => setIsOpen(false), + }) + ) : ( + { + setEditState({ ...columnFilter, value }) + }} + onRemove={() => { + onValueChange(value.filter(prevItem => prevItem.id !== columnFilter.id)) + setEditState(undefined) + }} + onClose={() => setIsOpen(false)} + /> + )}} ) diff --git a/packages/hightide-web/src/components/user-interaction/data/FilterOperator.tsx b/packages/hightide-web/src/components/interaction/data/FilterOperator.tsx similarity index 75% rename from packages/hightide-web/src/components/user-interaction/data/FilterOperator.tsx rename to packages/hightide-web/src/components/interaction/data/FilterOperator.tsx index 624a7498..26b98b08 100644 --- a/packages/hightide-web/src/components/user-interaction/data/FilterOperator.tsx +++ b/packages/hightide-web/src/components/interaction/data/FilterOperator.tsx @@ -10,6 +10,7 @@ import { CircleDashed, CircleDot } from 'lucide-react' +import { Icon } from '../../visualization/Icon' import type { DataType } from './data-types' import type { ReactNode } from 'react' @@ -85,16 +86,16 @@ type OperatorInfoResult = { } const getOperatorInfo = (operator: FilterOperator) : OperatorInfoResult => { switch (operator) { - case 'equals': return { icon: , translationKey: 'equals', replacementTranslationKey: 'rEquals' } - case 'notEquals': return { icon: , translationKey: 'notEquals', replacementTranslationKey: 'rNotEquals' } - case 'contains': return { icon: , translationKey: 'contains', replacementTranslationKey: 'rContains' } - case 'notContains': return { icon: , translationKey: 'notContains', replacementTranslationKey: 'rNotContains' } - case 'startsWith': return { icon: , translationKey: 'startsWith', replacementTranslationKey: 'rStartsWith' } - case 'endsWith': return { icon: , translationKey: 'endsWith', replacementTranslationKey: 'rEndsWith' } + case 'equals': return { icon: , translationKey: 'equals', replacementTranslationKey: 'rEquals' } + case 'notEquals': return { icon: , translationKey: 'notEquals', replacementTranslationKey: 'rNotEquals' } + case 'contains': return { icon: , translationKey: 'contains', replacementTranslationKey: 'rContains' } + case 'notContains': return { icon: , translationKey: 'notContains', replacementTranslationKey: 'rNotContains' } + case 'startsWith': return { icon: , translationKey: 'startsWith', replacementTranslationKey: 'rStartsWith' } + case 'endsWith': return { icon: , translationKey: 'endsWith', replacementTranslationKey: 'rEndsWith' } case 'greaterThan': return { icon: (
        - - + +
        ), translationKey: 'greaterThanOrEqual', @@ -102,22 +103,22 @@ const getOperatorInfo = (operator: FilterOperator) : OperatorInfoResult => { } case 'greaterThanOrEqual': return { icon: (
        - - + +
        ), translationKey: 'greaterThanOrEqual', replacementTranslationKey: 'rGreaterThanOrEqual' } case 'lessThan': return { - icon: , + icon: , translationKey: 'lessThan', replacementTranslationKey: 'rLessThan' } case 'lessThanOrEqual': return { icon: (
        - - + +
        ), translationKey: 'lessThanOrEqual', @@ -125,8 +126,8 @@ const getOperatorInfo = (operator: FilterOperator) : OperatorInfoResult => { } case 'between': return { icon: (
        - - + +
        ), translationKey: 'between', @@ -134,17 +135,17 @@ const getOperatorInfo = (operator: FilterOperator) : OperatorInfoResult => { } case 'notBetween': return { icon: (
        - - + +
        ), translationKey: 'notBetween', replacementTranslationKey: 'rNotBetween' } - case 'isTrue': return { icon: , translationKey: 'isTrue', replacementTranslationKey: 'isTrue' } - case 'isFalse': return { icon: , translationKey: 'isFalse', replacementTranslationKey: 'isFalse' } - case 'isUndefined': return { icon: , translationKey: 'isUndefined', replacementTranslationKey: 'isUndefined' } - case 'isNotUndefined': return { icon: , translationKey: 'isNotUndefined', replacementTranslationKey: 'isNotUndefined' } + case 'isTrue': return { icon: , translationKey: 'isTrue', replacementTranslationKey: 'isTrue' } + case 'isFalse': return { icon: , translationKey: 'isFalse', replacementTranslationKey: 'isFalse' } + case 'isUndefined': return { icon: , translationKey: 'isUndefined', replacementTranslationKey: 'isUndefined' } + case 'isNotUndefined': return { icon: , translationKey: 'isNotUndefined', replacementTranslationKey: 'isNotUndefined' } default: return { icon: null, translationKey: 'unknown translation key', replacementTranslationKey: 'unknown' } } } diff --git a/packages/hightide-web/src/components/user-interaction/data/FilterOperatorLabel.tsx b/packages/hightide-web/src/components/interaction/data/FilterOperatorLabel.tsx similarity index 100% rename from packages/hightide-web/src/components/user-interaction/data/FilterOperatorLabel.tsx rename to packages/hightide-web/src/components/interaction/data/FilterOperatorLabel.tsx diff --git a/packages/hightide-web/src/components/user-interaction/data/FilterPopUp.tsx b/packages/hightide-web/src/components/interaction/data/FilterPopUp.tsx similarity index 92% rename from packages/hightide-web/src/components/user-interaction/data/FilterPopUp.tsx rename to packages/hightide-web/src/components/interaction/data/FilterPopUp.tsx index f443c6bf..cb476223 100644 --- a/packages/hightide-web/src/components/user-interaction/data/FilterPopUp.tsx +++ b/packages/hightide-web/src/components/interaction/data/FilterPopUp.tsx @@ -2,16 +2,16 @@ import { useHightideTranslation } from '@helpwave/hightide-utils/context/transla import { Visibility } from '../../layout/Visibility' import { IconButton } from '../IconButton' import { Check, TrashIcon } from 'lucide-react' -import { PopUp, type PopUpProps } from '../../layout/popup/PopUp' +import { PopUp, type PopUpProps } from '../../layout/PopUp/PopUp' import type { FilterValue } from './filter-function' import type { FilterOperator } from './FilterOperator' import { FilterOperatorUtils } from './FilterOperator' import type { ReactNode } from 'react' import { forwardRef, useEffect, useId, useMemo, useState } from 'react' -import { Select } from '../Select/Select' -import { Input } from '../input/Input' -import { DateTimeInput } from '../input/DateTimeInput' -import { MultiSelect } from '../MultiSelect/MultiSelect' +import { Select } from '../../data-input/Select/Select' +import { TextInput } from '../../data-input/input/TextInput' +import { DateTimeInput } from '../../data-input/input/DateTimeInput' +import { MultiSelect } from '../../data-input/MultiSelect/MultiSelect' import type { DataType } from './data-types' import clsx from 'clsx' import { FilterOperatorLabel } from './FilterOperatorLabel' @@ -70,11 +70,10 @@ export const FilterBasePopUp = forwardRef( {name ?? translation('filter')} { onValueChange({ dataType: 'text', @@ -160,7 +155,11 @@ export const TextFilterPopUp = forwardRef(func parameter: { ...parameter, stringValue: searchText }, }) }} - className="min-w-64" + inputProps={{ + id: ids.search, + placeholder: translation('value'), + className: 'min-w-64', + }} />
        @@ -205,11 +204,8 @@ export const NumberFilterPopUp = forwardRef(fu
        - { const num = Number(text) onValueChange({ @@ -218,16 +214,18 @@ export const NumberFilterPopUp = forwardRef(fu parameter: { ...parameter, numberMin: isNaN(num) ? undefined : num }, }) }} - className="min-w-64" + inputProps={{ + id: ids.min, + type: 'number', + placeholder: '0', + className: 'min-w-64', + }} />
        - { const num = Number(text) onValueChange({ @@ -236,15 +234,18 @@ export const NumberFilterPopUp = forwardRef(fu parameter: { ...parameter, numberMax: isNaN(num) ? undefined : num }, }) }} - className="min-w-64" + inputProps={{ + id: ids.max, + type: 'number', + placeholder: '1', + className: 'min-w-64', + }} />
        - { const num = Number(text) onValueChange({ @@ -253,7 +254,11 @@ export const NumberFilterPopUp = forwardRef(fu parameter: { ...parameter, numberValue: isNaN(num) ? undefined : num }, }) }} - className="min-w-64" + inputProps={{ + type: 'number', + placeholder: '0', + className: 'min-w-64', + }} /> @@ -300,8 +305,8 @@ export const DateFilterPopUp = forwardRef(func { + onValueUpdate={setTemporaryMinDateValue} + onValueCommit={dateValue => { if (dateValue && parameter.dateMax && dateValue > parameter.dateMax) { if (!parameter.dateMin) { onValueChange({ @@ -336,8 +341,8 @@ export const DateFilterPopUp = forwardRef(func { + onValueUpdate={setTemporaryMaxDateValue} + onValueCommit={dateValue => { if (dateValue && parameter.dateMin && dateValue < parameter.dateMin) { if (!parameter.dateMax) { onValueChange({ @@ -373,7 +378,7 @@ export const DateFilterPopUp = forwardRef(func { + onValueUpdate={compareDate => { onValueChange({ dataType: 'date', operator, @@ -435,8 +440,8 @@ export const DatetimeFilterPopUp = forwardRef( id={ids.startDate} mode="dateTime" value={temporaryMinDateValue ?? parameter.dateMin ?? null} - onValueChange={setTemporaryMinDateValue} - onEditComplete={dateValue => { + onValueUpdate={setTemporaryMinDateValue} + onValueCommit={dateValue => { if (dateValue && parameter.dateMax && dateValue > parameter.dateMax) { if (!parameter.dateMin) { onValueChange({ @@ -470,8 +475,8 @@ export const DatetimeFilterPopUp = forwardRef( id={ids.endDate} mode="dateTime" value={temporaryMaxDateValue ?? parameter.dateMax ?? null} - onValueChange={setTemporaryMaxDateValue} - onEditComplete={dateValue => { + onValueUpdate={setTemporaryMaxDateValue} + onValueCommit={dateValue => { if (dateValue && parameter.dateMin && dateValue < parameter.dateMin) { if (!parameter.dateMax) { onValueChange({ @@ -508,7 +513,7 @@ export const DatetimeFilterPopUp = forwardRef( id={ids.compareDate} mode="dateTime" value={parameter.dateValue ?? null} - onValueChange={compareDate => { + onValueUpdate={compareDate => { onValueChange({ dataType: 'dateTime', operator, @@ -590,7 +595,7 @@ export const TagsFilterPopUp = forwardRef( { + onValueUpdate={(selected) => { onValueChange({ dataType: 'multiTags', operator, @@ -654,7 +659,7 @@ export const TagsSingleFilterPopUp = forwardRef { + onValueUpdate={(selected) => { onValueChange({ dataType: 'singleTag', operator, @@ -671,7 +676,7 @@ export const TagsSingleFilterPopUp = forwardRef { - if (value) { - setValue(MathUtil.clamp(Number(value), 1, pageCount).toString()) - } else { - setValue(value) - } + isDisabled={noPages} + onValueChange={next => { + const nextValue = next + ? MathUtil.clamp(Number(next), 1, pageCount).toString() + : next + setValue(nextValue) + edit.setTimer() }} - onEditComplete={value => { - changePage(MathUtil.clamp(Number(value) - 1, 0, pageCount - 1)) + inputProps={{ + className: clsx('w-24 text-center font-bold input-indicator-hidden h-10'), + type: 'number', + min: 1, + max: pageCount, + onBlur: () => { + edit.completeNow() + }, }} - editCompleteOptions={{ delay: 800 }} /> {translation('of')} changePage(pageIndex + 1)} disabled={onLastPage || noPages} - > - - + icon={ChevronRight} + /> changePage(pageCount - 1)} disabled={onLastPage || noPages} - > - - + icon={ChevronLast} + />
        ) } diff --git a/packages/hightide-web/src/components/layout/navigation/StepperBar.tsx b/packages/hightide-web/src/components/interaction/navigation/StepperBar.tsx similarity index 90% rename from packages/hightide-web/src/components/layout/navigation/StepperBar.tsx rename to packages/hightide-web/src/components/interaction/navigation/StepperBar.tsx index e86fa647..437b8ef6 100644 --- a/packages/hightide-web/src/components/layout/navigation/StepperBar.tsx +++ b/packages/hightide-web/src/components/interaction/navigation/StepperBar.tsx @@ -1,7 +1,7 @@ import { Check, ChevronLeft, ChevronRight } from 'lucide-react' import { useHightideTranslation } from '@helpwave/hightide-utils/context/translation' import { range } from '@helpwave/hightide-utils/utils' -import { Button } from '../../user-interaction/Button' +import { Button } from '../Button' import clsx from 'clsx' import { useControlledState } from '@helpwave/hightide-utils/hooks' @@ -65,9 +65,8 @@ export const StepperBar = ({ onClick={() => { update(currentStep - 1) }} - className="flex-row-1 items-center justify-center" + leading={ChevronLeft} > - {translation('back')}
        @@ -95,11 +94,10 @@ export const StepperBar = ({
        )} @@ -108,9 +106,8 @@ export const StepperBar = ({
        diff --git a/packages/hightide-web/src/components/layout/navigation/index.ts b/packages/hightide-web/src/components/interaction/navigation/index.ts similarity index 100% rename from packages/hightide-web/src/components/layout/navigation/index.ts rename to packages/hightide-web/src/components/interaction/navigation/index.ts diff --git a/packages/hightide-web/src/components/layout/navigation/navigation-menus/NavigationContext.tsx b/packages/hightide-web/src/components/interaction/navigation/navigation-menus/NavigationContext.tsx similarity index 100% rename from packages/hightide-web/src/components/layout/navigation/navigation-menus/NavigationContext.tsx rename to packages/hightide-web/src/components/interaction/navigation/navigation-menus/NavigationContext.tsx diff --git a/packages/hightide-web/src/components/layout/navigation/navigation-menus/VerticalNavigationMenu.tsx b/packages/hightide-web/src/components/interaction/navigation/navigation-menus/VerticalNavigationMenu.tsx similarity index 96% rename from packages/hightide-web/src/components/layout/navigation/navigation-menus/VerticalNavigationMenu.tsx rename to packages/hightide-web/src/components/interaction/navigation/navigation-menus/VerticalNavigationMenu.tsx index 6d292556..a5d522f4 100644 --- a/packages/hightide-web/src/components/layout/navigation/navigation-menus/VerticalNavigationMenu.tsx +++ b/packages/hightide-web/src/components/interaction/navigation/navigation-menus/VerticalNavigationMenu.tsx @@ -1,5 +1,6 @@ import type { ElementType } from 'react' import { + NavigationProvider, type NavigationProviderProps } from './NavigationContext' @@ -29,7 +30,7 @@ export function VerticalNavigationMenu({ return ( -
          +
            {items.map((item) => ( ))} diff --git a/packages/hightide-web/src/components/interaction/navigation/navigation-menus/VerticalNavigationMenuItem.tsx b/packages/hightide-web/src/components/interaction/navigation/navigation-menus/VerticalNavigationMenuItem.tsx new file mode 100644 index 00000000..615df072 --- /dev/null +++ b/packages/hightide-web/src/components/interaction/navigation/navigation-menus/VerticalNavigationMenuItem.tsx @@ -0,0 +1,121 @@ +import type { ElementType, HTMLAttributeAnchorTarget, ReactNode } from 'react' +import { useId } from 'react' +import { ExternalLink } from 'lucide-react' +import { Icon } from '../../../visualization/Icon' +import { ExpansionIcon } from '../../../visualization/ExpansionIcon' +import { useNavigationItem } from './NavigationContext' +import type { NavigationItemData } from './types' + +export interface LinkComponentProps { + 'className'?: string, + 'href': string, + 'target'?: HTMLAttributeAnchorTarget | undefined, + 'rel'?: string | undefined, + 'aria-current'?: 'page' | undefined, + 'children'?: ReactNode, +} + +export type VerticalNavigationMenuItemProps = NavigationItemData & { + depth?: number, + forceMountDepth?: number, + LinkComponent?: ElementType, +} + +const DefaultLink: ElementType = 'a' + +export function VerticalNavigationMenuItem({ + id, + label, + depth = 0, + forceMountDepth = 2, + LinkComponent = DefaultLink, + ...item +}: VerticalNavigationMenuItemProps) { + const groupId = useId() + const { + expanded, + isActive, + toggleExpansion, + } = useNavigationItem(id) + + const childItems = 'items' in item && item.items != null ? item.items : undefined + const url = 'url' in item ? item.url : undefined + const external = 'external' in item ? item.external ?? false : false + const hasChildren = childItems != null && childItems.length > 0 + + const labelContent = url != null ? ( + +
            + {label} + {external && ( + + )} +
            +
            + ) : ( +
            + {label} +
            + ) + + const interactable = url != null || hasChildren + const forceMountChildren = depth < forceMountDepth + + return ( +
          • + {hasChildren ? ( + + ) : ( +
            +
            + {labelContent} +
            +
            + )} + + {childItems != null && childItems.length > 0 && (expanded || forceMountChildren) && ( + + )} +
          • + ) +} diff --git a/packages/hightide-web/src/components/layout/navigation/navigation-menus/index.ts b/packages/hightide-web/src/components/interaction/navigation/navigation-menus/index.ts similarity index 100% rename from packages/hightide-web/src/components/layout/navigation/navigation-menus/index.ts rename to packages/hightide-web/src/components/interaction/navigation/navigation-menus/index.ts diff --git a/packages/hightide-web/src/components/interaction/navigation/navigation-menus/types.ts b/packages/hightide-web/src/components/interaction/navigation/navigation-menus/types.ts new file mode 100644 index 00000000..9d6a0cee --- /dev/null +++ b/packages/hightide-web/src/components/interaction/navigation/navigation-menus/types.ts @@ -0,0 +1,34 @@ +import type { ReactNode } from 'react' +import type { TreeNode } from '@helpwave/hightide-utils/hooks' + +type NavigationItemBase = { + id: string, + label: ReactNode, +} + +export type NavigationLinkItem = NavigationItemBase & { + url: string, + external?: boolean, + items?: never, +} + +export type NavigationGroupItem = NavigationItemBase & { + items: NavigationItemData[], + url?: never, + external?: never, +} + +export type NavigationLabelItem = NavigationItemBase & { + url?: never, + external?: never, + items?: never, +} + +export type NavigationItemData = NavigationLinkItem | NavigationGroupItem | NavigationLabelItem + +export function toTreeNodes(items: ReadonlyArray): TreeNode[] { + return items.map((item) => ({ + id: item.id, + items: 'items' in item && item.items != null ? toTreeNodes(item.items) : [], + })) +} diff --git a/packages/hightide-web/src/components/layout/Card.tsx b/packages/hightide-web/src/components/layout/Card.tsx new file mode 100644 index 00000000..e87b6691 --- /dev/null +++ b/packages/hightide-web/src/components/layout/Card.tsx @@ -0,0 +1,24 @@ +import type { HTMLAttributes } from 'react' +import clsx from 'clsx' +import { ColoringUtils } from '../interaction' + +export type CardSize = 'sm' | 'md' | 'lg' + +export type CardProps = HTMLAttributes & { + size?: CardSize, +} + +export function Card({ + size = 'md', + className, + ...props +}: CardProps) { + return ( +
            + ) +} diff --git a/packages/hightide-web/src/components/layout/Carousel.tsx b/packages/hightide-web/src/components/layout/Carousel.tsx deleted file mode 100644 index d074bd88..00000000 --- a/packages/hightide-web/src/components/layout/Carousel.tsx +++ /dev/null @@ -1,452 +0,0 @@ -import type { HTMLAttributes, ReactNode } from 'react' -import React, { - createContext, - forwardRef, - useCallback, - useContext, - useEffect, - useId, - useMemo, - useRef, - useState -} from 'react' -import clsx from 'clsx' -import { ChevronLeft, ChevronRight } from 'lucide-react' -import { createLoopingListWithIndex, range } from '@helpwave/hightide-utils/utils' -import { useHightideTranslation } from '@helpwave/hightide-utils/context/translation' -import { useEventCallbackStabilizer } from '@helpwave/hightide-utils/hooks' -import { IconButton } from '../user-interaction/IconButton' - -// -// CarouselContext -// -type CarouselContextType = { - id: string, - currentIndex: number, - slideCount: number, - isLooping: boolean, -} - -const CarouselContext = createContext(null) - -const useCarouselContext = () => { - const context = useContext(CarouselContext) - if (!context) { - throw Error('useCarouselContext must be used within CarouselContext') - } - return context -} - -// -// CarouselTab -// -type CarouselTabsProps = { - onChange: (index: number) => void, -} - -export default function CarouselTabs({ - onChange, -}: CarouselTabsProps) { - const translation = useHightideTranslation() - const { id, slideCount, currentIndex, isLooping } = useCarouselContext() - - const tabRefs = useRef<(HTMLButtonElement | null)[]>([]) - - const handleKeyDown = (event: React.KeyboardEvent, index: number) => { - let newIndex = index - if (event.key === 'ArrowRight') { - newIndex = isLooping ? (index + 1) % slideCount : Math.max(index + 1, slideCount - 1) - } else if (event.key === 'ArrowLeft') { - newIndex = isLooping ? (index - 1 + slideCount) % slideCount : Math.max(index - 1, 0) - } else { - return - } - event.preventDefault() - onChange(newIndex) - tabRefs.current[newIndex]?.focus() - } - - return ( -
            - {range(slideCount).map((index) => { - const isSelected = currentIndex === index - return ( -
            - ) -} - -// -// CarouselSlide -// -export interface CarouselSlideProps extends HTMLAttributes { - isSelected: boolean, - index: number, -} - -export const CarouselSlide = forwardRef( - function CarouselSlide({ - index, - isSelected, - ...props - }, ref) { - const translation = useHightideTranslation() - const { id, slideCount } = useCarouselContext() - - return ( -
            - ) - } -) - -// -// Carousel -// -type DragState = { - dragStartX: number, - dragOffsetX: number, -} - -type ItemType = { - item: ReactNode, - index: number, -} - -export type CarouselProps = Omit, 'children'> & { - children: ReactNode[], - animationTime?: number, - isLooping?: boolean, - isAutoPlaying?: boolean, - autoLoopingTimeOut?: number, - autoLoopAnimationTime?: number, - hintNext?: boolean, - arrows?: boolean, - dots?: boolean, - /** - * Percentage that is allowed to be scrolled further - */ - overScrollThreshold?: number, - blurColor?: string, - heightClassName?: string, - slideClassName?: string, - slideContainerProps?: HTMLAttributes, - onSlideChanged?: (index: number) => void, -} - -export const Carousel = ({ - children, - animationTime = 200, - isLooping = false, - isAutoPlaying = false, - autoLoopingTimeOut = 5000, - autoLoopAnimationTime = 1000, - hintNext = false, - arrows = false, - dots = true, - blurColor = 'from-background', - heightClassName = 'h-96', - slideClassName = 'w-[70%] desktop:w-1/2', - slideContainerProps, - onSlideChanged, - ...props -}: CarouselProps) => { - const translation = useHightideTranslation() - const onSlideChangedStable = useEventCallbackStabilizer(onSlideChanged) - const slideRefs = useRef([]) - const [currentIndex, setCurrentIndex] = useState(0) - const [hasFocus, setHasFocus] = useState(false) - const [dragState, setDragState] = useState() - const isPaused = hasFocus - const carouselContainerRef = useRef(null) - const [disableClick, setDisableClick] = useState(false) - - const timeOut = useRef | undefined>(undefined) - - const length = useMemo(() => children.length, [children]) - const paddingItemCount = 3 // The number of items to append left and right of the list to allow for clean transition when looping - - const generatedId = 'carousel' + useId() - const id = props.id ?? generatedId - - // Validation - if (isAutoPlaying && !isLooping) { - console.error('When isAutoLooping is true, isLooping should also be true') - isLooping = true - } - autoLoopingTimeOut = Math.max(0, autoLoopingTimeOut) // time between transitions - animationTime = Math.max(100, animationTime) // in ms, must be > 0 - autoLoopAnimationTime = Math.max(200, autoLoopAnimationTime) - - useEffect(() => { - const carousel = carouselContainerRef.current - - if (carousel) { - function onFocus() { - setHasFocus(true) - } - - function onBlur() { - setHasFocus(false) - } - - - carousel?.addEventListener('focusin', onFocus) - carousel?.addEventListener('focusout', onBlur) - return () => { - carousel?.removeEventListener('focusin', onFocus) - carousel?.removeEventListener('focusin', onFocus) - } - } - }, []) - - const getStyleOffset = (index: number) => { - const baseOffset = -50 + (index - currentIndex) * 100 - return `${baseOffset}%` - } - - const canGoLeft = () => { - return isLooping || currentIndex !== 0 - } - - const canGoRight = useCallback(() => { - return isLooping || currentIndex !== length - 1 - }, [currentIndex, isLooping, length]) - - const left = () => { - if (canGoLeft()) { - setCurrentIndex((currentIndex + length - 1) % length) - } - } - - const right = useCallback(() => { - if (canGoRight()) { - setCurrentIndex((currentIndex + length + 1) % length) - } - }, [canGoRight, currentIndex, length]) - - useEffect(() => { - if (!timeOut.current && !isPaused) { - if (autoLoopingTimeOut > 0) { - timeOut.current = setTimeout(() => { - right() - timeOut.current = undefined - }, autoLoopingTimeOut) - } else { - right() - } - } - if ((isPaused || !!dragState) && timeOut.current) { - clearTimeout(timeOut.current) - timeOut.current = undefined - } - }, [right, isPaused, autoLoopingTimeOut, dragState]) - - - let items: ItemType[] = children.map((item, index) => ({ - index, - item - })) - let before: ItemType[] = [] - let after: ItemType[] = [] - if (isLooping) { - before = createLoopingListWithIndex(children, length - 1, paddingItemCount, false).reverse().map(([index, item]) => ({ - index, - item - })) - after = createLoopingListWithIndex(children, 0, paddingItemCount).map(([index, item]) => ({ - index, - item - })) - - items = [...before, ...items, ...after] - } - - const handlePointerDown = (e: React.PointerEvent) => { - setDragState({ - dragOffsetX: 0, - dragStartX: e.clientX, - }) - } - - const handlePointerMove = (e: React.PointerEvent) => { - if (!dragState) return - setDragState(prevState => { - if(!prevState) return prevState - return { dragStartX: prevState.dragStartX, dragOffsetX: e.clientX - prevState.dragStartX } - }) - } - - const handlePointerUp = () => { - if (!dragState) return - if (dragState.dragOffsetX > 50) { - left() - } else if (dragState.dragOffsetX < -50) { - right() - } - setDragState(undefined) - } - - useEffect(() => { - setDisableClick(!dragState) - }, [dragState]) - - useEffect(() => { - onSlideChangedStable(currentIndex) - }, [currentIndex, onSlideChangedStable]) - - return ( - -
            -
            - {hintNext ? ( -
            -
            - {items.map(({ - item, - index - }, listIndex) => { - const isInItems = before.length <= listIndex && listIndex < items.length - after.length - - return ( - { - const array = slideRefs.current - if(!array || !el || !isInItems) return - array[index] = el - }} - key={listIndex} - index={index} - isSelected={isInItems && currentIndex === index} - className={clsx( - `absolute left-[50%] h-full overflow-hidden transition-transform ease-in-out`, - slideClassName - )} - onClick={() => !disableClick && setCurrentIndex(index)} - style={{ - translate: `calc(${getStyleOffset(listIndex - (isLooping ? paddingItemCount : 0))} + ${dragState ? dragState.dragOffsetX : 0}px)`, - transitionDuration: dragState ? '0ms' : ((isAutoPlaying && !isPaused ? autoLoopAnimationTime : animationTime) + 'ms'), - }} - > - {item} - - ) - })} -
            -
            -
            -
            - ) : ( -
            { - const array = slideRefs.current - if(!array || !el) return - array[currentIndex] = el - }} - className={clsx('px-16 h-full')} - - tabIndex={0} - role="group" - aria-roledescription={translation('slide')} - aria-label={translation('slideOf', { - index: (currentIndex + 1), - length: items.length - })} - > - {children[currentIndex]} -
            - )} - {arrows && ( - <> - left()} - > - - - right()} - > - - - - )} -
            - {dots && ()} -
            - - ) -} diff --git a/packages/hightide-web/src/components/layout/Carousel/Carousel.tsx b/packages/hightide-web/src/components/layout/Carousel/Carousel.tsx new file mode 100644 index 00000000..208158f1 --- /dev/null +++ b/packages/hightide-web/src/components/layout/Carousel/Carousel.tsx @@ -0,0 +1,96 @@ +import type { HTMLAttributes, ReactNode } from 'react' +import { Children, isValidElement } from 'react' +import clsx from 'clsx' + +import type { CarouselArrowsProps } from './CarouselArrows' +import { CarouselArrows } from './CarouselArrows' +import type { CarouselContainerProps } from './CarouselContainer' +import { CarouselContainer } from './CarouselContainer' +import { CarouselContext } from './CarouselContext' +import type { CarouselFadeLayoverProps } from './CarouselFadeLayover' +import { CarouselFadeLayover } from './CarouselFadeLayover' +import type { CarouselRootProps } from './CarouselRoot' +import { CarouselRoot } from './CarouselRoot' +import type { CarouselSlideProps } from './CarouselSlide' +import { CarouselSlide } from './CarouselSlide' +import type { CarouselStepperProps } from './CarouselStepper' +import { CarouselStepper } from './CarouselStepper' + +export type CarouselProps = Omit & { + children: ReactNode, + arrows?: boolean, + dots?: boolean, + blurColor?: string, + heightClassName?: string, + slideClassName?: string, + slideContainerProps?: HTMLAttributes, + arrowsProps?: CarouselArrowsProps, + fadeLayoverProps?: CarouselFadeLayoverProps, + stepperProps?: CarouselStepperProps, + containerProps?: Omit, +} + +function slideElements(children: ReactNode) { + return Children.map(children, (child, index) => { + if (isValidElement(child) && child.type === CarouselSlide) { + return child + } + return ( + + {child} + + ) + }) +} + +function CarouselComponent({ + children, + arrows = false, + dots = true, + blurColor = 'from-background', + heightClassName = 'h-96', + slideClassName = 'w-[70%] desktop:w-1/2', + slideContainerProps, + arrowsProps, + fadeLayoverProps, + stepperProps, + containerProps, + hintNext = false, + ...props +}: CarouselProps) { + return ( + + + {slideElements(children)} + {hintNext && ( + + )} + {arrows && } + + {dots && } + + ) +} + +const Carousel = Object.assign(CarouselComponent, { + Root: CarouselRoot, + Container: CarouselContainer, + Slide: CarouselSlide, + Arrows: CarouselArrows, + FadeLayover: CarouselFadeLayover, + Stepper: CarouselStepper, + Context: CarouselContext, + Consumer: CarouselContext.Consumer, +}) + +export { Carousel } +export type { CarouselRootProps, CarouselContainerProps, CarouselSlideProps, CarouselFadeLayoverProps, CarouselStepperProps } +export type { CarouselArrowsProps } diff --git a/packages/hightide-web/src/components/layout/Carousel/CarouselArrows.tsx b/packages/hightide-web/src/components/layout/Carousel/CarouselArrows.tsx new file mode 100644 index 00000000..c9a115d9 --- /dev/null +++ b/packages/hightide-web/src/components/layout/Carousel/CarouselArrows.tsx @@ -0,0 +1,38 @@ +import type { HTMLAttributes } from 'react' +import { ChevronLeft, ChevronRight } from 'lucide-react' +import { useHightideTranslation } from '@helpwave/hightide-utils/context/translation' + +import { IconButton } from '../../interaction/IconButton' +import { useCarouselContext } from './CarouselContext' + +export type CarouselArrowsProps = HTMLAttributes + +export function CarouselArrows(_props: CarouselArrowsProps) { + const translation = useHightideTranslation() + const { goPrevious, goNext, canGoPrevious, canGoNext } = useCarouselContext() + + return ( + <> + + + + ) +} diff --git a/packages/hightide-web/src/components/layout/Carousel/CarouselContainer.tsx b/packages/hightide-web/src/components/layout/Carousel/CarouselContainer.tsx new file mode 100644 index 00000000..f63277fc --- /dev/null +++ b/packages/hightide-web/src/components/layout/Carousel/CarouselContainer.tsx @@ -0,0 +1,176 @@ +import type { HTMLAttributes, PointerEvent, ReactElement, ReactNode } from 'react' +import { Children, cloneElement, isValidElement, useEffect, useLayoutEffect, useRef, useState } from 'react' +import clsx from 'clsx' +import { createLoopingListWithIndex } from '@helpwave/hightide-utils/utils' + +import { useCarouselContext } from './CarouselContext' +import type { CarouselSlideProps } from './CarouselSlide' +import { CarouselSlide } from './CarouselSlide' + +const loopPadding = 3 + +export type CarouselContainerProps = HTMLAttributes & { + slideClassName?: string, +} + +function isCarouselSlide(node: ReactNode): node is ReactElement { + return isValidElement(node) && node.type === CarouselSlide +} + +export function CarouselContainer({ + children, + slideClassName, + ...props +}: CarouselContainerProps) { + const { + currentIndex, + hintNext, + isLooping, + setSlideCount, + setIsDragging, + goTo, + goPrevious, + goNext, + transitionDuration, + } = useCarouselContext() + const [drag, setDrag] = useState<{ startX: number, offsetX: number }>() + const suppressClickRef = useRef(false) + + const slides: ReactElement[] = [] + const overlays: ReactNode[] = [] + Children.forEach(children, (child) => { + if (isCarouselSlide(child)) { + slides.push(child) + } else { + overlays.push(child) + } + }) + + useLayoutEffect(() => { + setSlideCount(slides.length) + }, [setSlideCount, slides.length]) + + useEffect(() => { + setIsDragging(drag !== undefined) + }, [drag, setIsDragging]) + + const handlePointerDown = (event: PointerEvent) => { + setDrag({ + startX: event.clientX, + offsetX: 0, + }) + } + + const handlePointerMove = (event: PointerEvent) => { + setDrag((previous) => { + if (!previous) { + return previous + } + return { + startX: previous.startX, + offsetX: event.clientX - previous.startX, + } + }) + } + + const handlePointerUp = () => { + if (!drag) { + return + } + if (drag.offsetX > 50) { + goPrevious() + } else if (drag.offsetX < -50) { + goNext() + } + suppressClickRef.current = Math.abs(drag.offsetX) > 5 + setDrag(undefined) + } + + const positionedSlides = () => { + const padding = isLooping ? loopPadding : 0 + let entries = slides.map((element, index) => ({ + index, + element, + isClone: false, + key: `slide-${index}`, + })) + if (isLooping && slides.length > 0) { + const before = createLoopingListWithIndex(slides, slides.length - 1, loopPadding, false) + .reverse() + .map(([index, element], listIndex) => ({ + index, + element, + isClone: true, + key: `before-${listIndex}`, + })) + const after = createLoopingListWithIndex(slides, 0, loopPadding) + .map(([index, element], listIndex) => ({ + index, + element, + isClone: true, + key: `after-${listIndex}`, + })) + entries = [...before, ...entries, ...after] + } + + return entries.map((entry, listIndex) => { + const visualIndex = listIndex - padding + const offset = -50 + (visualIndex - currentIndex) * 100 + const dragOffset = drag ? drag.offsetX : 0 + return cloneElement(entry.element, { + key: entry.key, + index: entry.index, + isSelected: !entry.isClone && entry.index === currentIndex, + isClone: entry.isClone, + className: clsx(slideClassName, entry.element.props.className), + style: { + ...entry.element.props.style, + translate: `calc(${offset}% + ${dragOffset}px)`, + transitionDuration: drag ? '0ms' : `${transitionDuration}ms`, + }, + onClick: (event) => { + entry.element.props.onClick?.(event) + if (suppressClickRef.current) { + suppressClickRef.current = false + return + } + goTo(entry.index) + }, + }) + }) + } + + const currentSlide = slides[currentIndex] + const framedSlide = currentSlide && cloneElement(currentSlide, { + index: currentIndex, + isSelected: true, + }) + + return ( +
            + {hintNext ? ( +
            +
            + {positionedSlides()} +
            +
            + ) : ( +
            + {framedSlide} +
            + )} + {overlays} +
            + ) +} diff --git a/packages/hightide-web/src/components/layout/Carousel/CarouselContext.tsx b/packages/hightide-web/src/components/layout/Carousel/CarouselContext.tsx new file mode 100644 index 00000000..f0a371ad --- /dev/null +++ b/packages/hightide-web/src/components/layout/Carousel/CarouselContext.tsx @@ -0,0 +1,42 @@ +import type { ReactNode } from 'react' +import { createContext, useContext } from 'react' + +export type CarouselContextState = { + id: string, + currentIndex: number, + slideCount: number, + setSlideCount: (count: number) => void, + isLooping: boolean, + hintNext: boolean, + transitionDuration: number, + isDragging: boolean, + setIsDragging: (isDragging: boolean) => void, + goTo: (index: number) => void, + goPrevious: () => void, + goNext: () => void, + canGoPrevious: boolean, + canGoNext: boolean, +} + +export const CarouselContext = createContext(null) + +export function useCarouselContext() { + const context = useContext(CarouselContext) + if (!context) { + throw new Error('Carousel components must be used within a Carousel.Root') + } + return context +} + +export type CarouselContextProviderProps = { + value: CarouselContextState, + children: ReactNode, +} + +export function CarouselContextProvider({ value, children }: CarouselContextProviderProps) { + return ( + + {children} + + ) +} diff --git a/packages/hightide-web/src/components/layout/Carousel/CarouselFadeLayover.tsx b/packages/hightide-web/src/components/layout/Carousel/CarouselFadeLayover.tsx new file mode 100644 index 00000000..1191be97 --- /dev/null +++ b/packages/hightide-web/src/components/layout/Carousel/CarouselFadeLayover.tsx @@ -0,0 +1,24 @@ +import type { HTMLAttributes } from 'react' +import clsx from 'clsx' + +export type CarouselFadeLayoverProps = HTMLAttributes + +export function CarouselFadeLayover({ + className, + ...props +}: CarouselFadeLayoverProps) { + return ( + <> +
            +
            + + ) +} diff --git a/packages/hightide-web/src/components/layout/Carousel/CarouselRoot.tsx b/packages/hightide-web/src/components/layout/Carousel/CarouselRoot.tsx new file mode 100644 index 00000000..149eeccf --- /dev/null +++ b/packages/hightide-web/src/components/layout/Carousel/CarouselRoot.tsx @@ -0,0 +1,162 @@ +import type { HTMLAttributes } from 'react' +import { useCallback, useEffect, useId, useMemo, useState } from 'react' +import clsx from 'clsx' +import { useHightideTranslation } from '@helpwave/hightide-utils/context/translation' +import { useStableEvent, useLogOnce } from '@helpwave/hightide-utils/hooks' +import { CarouselContextProvider } from './CarouselContext' + +export type CarouselRootProps = HTMLAttributes & { + animationTime?: number, + isLooping?: boolean, + isAutoPlaying?: boolean, + autoLoopingTimeOut?: number, + autoLoopAnimationTime?: number, + hintNext?: boolean, + onSlideChanged?: (index: number) => void, +} + +export function CarouselRoot({ + children, + animationTime = 200, + isLooping = false, + isAutoPlaying = false, + autoLoopingTimeOut = 5000, + autoLoopAnimationTime = 1000, + hintNext = false, + onSlideChanged, + ...props +}: CarouselRootProps) { + const translation = useHightideTranslation() + const onSlideChangedStable = useStableEvent(onSlideChanged) + const generatedId = useId() + const id = props.id ?? `carousel-${generatedId}` + const loops = isAutoPlaying || isLooping + const stepDuration = Math.max(100, animationTime) + const autoDuration = Math.max(200, autoLoopAnimationTime) + const autoDelay = Math.max(0, autoLoopingTimeOut) + + useLogOnce( + 'Carousel: isAutoPlaying requires isLooping', + isAutoPlaying && !isLooping, + { type: 'error' } + ) + + const [currentIndex, setCurrentIndex] = useState(0) + const [slideCount, setSlideCount] = useState(0) + const [isPaused, setIsPaused] = useState(false) + const [isDragging, setIsDragging] = useState(false) + + const goTo = useCallback((index: number) => { + setCurrentIndex((previous) => { + if (slideCount <= 0) { + return previous + } + if (loops) { + return (index % slideCount + slideCount) % slideCount + } + return Math.min(Math.max(index, 0), slideCount - 1) + }) + }, [loops, slideCount]) + + const canGoPrevious = loops || currentIndex !== 0 + const canGoNext = slideCount > 0 && (loops || currentIndex !== slideCount - 1) + + const goPrevious = useCallback(() => { + setCurrentIndex((previous) => { + if (slideCount <= 0) { + return previous + } + if (!loops && previous === 0) { + return previous + } + return (previous - 1 + slideCount) % slideCount + }) + }, [loops, slideCount]) + + const goNext = useCallback(() => { + setCurrentIndex((previous) => { + if (slideCount <= 0) { + return previous + } + if (!loops && previous === slideCount - 1) { + return previous + } + return (previous + 1) % slideCount + }) + }, [loops, slideCount]) + + useEffect(() => { + if (slideCount <= 0) { + return + } + if (currentIndex > slideCount - 1) { + setCurrentIndex(slideCount - 1) + } + }, [currentIndex, slideCount]) + + useEffect(() => { + onSlideChangedStable(currentIndex) + }, [currentIndex, onSlideChangedStable]) + + useEffect(() => { + if (!isAutoPlaying || isPaused || isDragging || slideCount <= 0) { + return + } + const timeout = setTimeout(() => { + goNext() + }, autoDelay) + return () => clearTimeout(timeout) + }, [autoDelay, goNext, isAutoPlaying, isDragging, isPaused, slideCount]) + + const transitionDuration = isAutoPlaying && !isPaused ? autoDuration : stepDuration + + const contextValue = useMemo(() => ({ + id, + currentIndex, + slideCount, + setSlideCount, + isLooping: loops, + hintNext, + transitionDuration, + isDragging, + setIsDragging, + goTo, + goPrevious, + goNext, + canGoPrevious, + canGoNext, + }), [ + canGoNext, + canGoPrevious, + currentIndex, + goNext, + goPrevious, + goTo, + hintNext, + id, + isDragging, + loops, + slideCount, + transitionDuration, + ]) + + return ( + +
            setIsPaused(true)} + onBlur={() => setIsPaused(false)} + > + {children} +
            +
            + ) +} diff --git a/packages/hightide-web/src/components/layout/Carousel/CarouselSlide.tsx b/packages/hightide-web/src/components/layout/Carousel/CarouselSlide.tsx new file mode 100644 index 00000000..cb40fc1f --- /dev/null +++ b/packages/hightide-web/src/components/layout/Carousel/CarouselSlide.tsx @@ -0,0 +1,41 @@ +import type { HTMLAttributes } from 'react' +import { forwardRef } from 'react' +import clsx from 'clsx' +import { useHightideTranslation } from '@helpwave/hightide-utils/context/translation' + +import { useCarouselContext } from './CarouselContext' + +export type CarouselSlideProps = HTMLAttributes & { + index?: number, + isSelected?: boolean, + isClone?: boolean, +} + +export const CarouselSlide = forwardRef(function CarouselSlide({ + index = 0, + isSelected = false, + isClone = false, + ...props +}, ref) { + const translation = useHightideTranslation() + const { id, slideCount } = useCarouselContext() + + return ( +
            + ) +}) diff --git a/packages/hightide-web/src/components/layout/Carousel/CarouselStepper.tsx b/packages/hightide-web/src/components/layout/Carousel/CarouselStepper.tsx new file mode 100644 index 00000000..8f0144c0 --- /dev/null +++ b/packages/hightide-web/src/components/layout/Carousel/CarouselStepper.tsx @@ -0,0 +1,68 @@ +import type { KeyboardEvent } from 'react' +import { useRef } from 'react' +import clsx from 'clsx' +import { range } from '@helpwave/hightide-utils/utils' +import { useHightideTranslation } from '@helpwave/hightide-utils/context/translation' + +import { useCarouselContext } from './CarouselContext' + +export type CarouselStepperProps = { + className?: string, +} + +export function CarouselStepper({ className }: CarouselStepperProps) { + const translation = useHightideTranslation() + const { id, slideCount, currentIndex, isLooping, goTo } = useCarouselContext() + const tabRefs = useRef<(HTMLButtonElement | null)[]>([]) + + const move = (index: number, direction: 1 | -1) => { + const next = isLooping + ? (index + direction + slideCount) % slideCount + : Math.min(Math.max(index + direction, 0), slideCount - 1) + goTo(next) + tabRefs.current[next]?.focus() + } + + const handleKeyDown = (event: KeyboardEvent, index: number) => { + if (event.key === 'ArrowRight') { + event.preventDefault() + move(index, 1) + } else if (event.key === 'ArrowLeft') { + event.preventDefault() + move(index, -1) + } + } + + return ( +
            + {range(slideCount).map((index) => { + const isSelected = currentIndex === index + return ( +
            + ) +} diff --git a/packages/hightide-web/src/components/layout/Carousel/index.ts b/packages/hightide-web/src/components/layout/Carousel/index.ts new file mode 100644 index 00000000..f3a18837 --- /dev/null +++ b/packages/hightide-web/src/components/layout/Carousel/index.ts @@ -0,0 +1,11 @@ +export { Carousel } from './Carousel' +export { CarouselRoot } from './CarouselRoot' +export { CarouselContainer } from './CarouselContainer' +export { CarouselSlide } from './CarouselSlide' +export { CarouselArrows } from './CarouselArrows' +export { CarouselFadeLayover } from './CarouselFadeLayover' +export { CarouselStepper } from './CarouselStepper' +export { useCarouselContext } from './CarouselContext' + +export type { CarouselProps, CarouselRootProps, CarouselContainerProps, CarouselSlideProps, CarouselArrowsProps, CarouselFadeLayoverProps, CarouselStepperProps } from './Carousel' +export type { CarouselContextState } from './CarouselContext' diff --git a/packages/hightide-web/src/components/layout/Divider.tsx b/packages/hightide-web/src/components/layout/Divider.tsx new file mode 100644 index 00000000..4157cd0a --- /dev/null +++ b/packages/hightide-web/src/components/layout/Divider.tsx @@ -0,0 +1,23 @@ +import type { HTMLAttributes } from 'react' +import clsx from 'clsx' + +export type DividerDirection = 'horizontal' | 'vertical' + +export type DividerProps = HTMLAttributes & { + direction?: DividerDirection, +} + +export function Divider({ + direction = 'horizontal', + className, + ...props +}: DividerProps) { + return ( +
            + ) +} diff --git a/packages/hightide-web/src/components/layout/drawer/Drawer.tsx b/packages/hightide-web/src/components/layout/Drawer/Drawer.tsx similarity index 100% rename from packages/hightide-web/src/components/layout/drawer/Drawer.tsx rename to packages/hightide-web/src/components/layout/Drawer/Drawer.tsx diff --git a/packages/hightide-web/src/components/layout/drawer/DrawerCloseButton.tsx b/packages/hightide-web/src/components/layout/Drawer/DrawerCloseButton.tsx similarity index 77% rename from packages/hightide-web/src/components/layout/drawer/DrawerCloseButton.tsx rename to packages/hightide-web/src/components/layout/Drawer/DrawerCloseButton.tsx index 82b0de6e..064f3fa2 100644 --- a/packages/hightide-web/src/components/layout/drawer/DrawerCloseButton.tsx +++ b/packages/hightide-web/src/components/layout/Drawer/DrawerCloseButton.tsx @@ -1,7 +1,7 @@ import { useHightideTranslation } from '@helpwave/hightide-utils/context/translation' import { X } from 'lucide-react' -import type { IconButtonProps } from '../../user-interaction/IconButton' -import { IconButton } from '../../user-interaction/IconButton' +import type { IconButtonProps } from '../../interaction/IconButton' +import { IconButton } from '../../interaction/IconButton' import { useDrawerContext } from './DrawerContext' export type DrawerCloseButtonProps = IconButtonProps @@ -20,7 +20,7 @@ export function DrawerCloseButton({ return ( - - + icon={X} + /> ) } diff --git a/packages/hightide-web/src/components/layout/drawer/DrawerContainer.tsx b/packages/hightide-web/src/components/layout/Drawer/DrawerContainer.tsx similarity index 95% rename from packages/hightide-web/src/components/layout/drawer/DrawerContainer.tsx rename to packages/hightide-web/src/components/layout/Drawer/DrawerContainer.tsx index f2bf985b..d31bf946 100644 --- a/packages/hightide-web/src/components/layout/drawer/DrawerContainer.tsx +++ b/packages/hightide-web/src/components/layout/Drawer/DrawerContainer.tsx @@ -2,13 +2,14 @@ import { forwardRef, useId, useMemo, useRef, type HTMLAttributes } from 'react' import { useFocusTrap } from '../../../hooks/focus/useFocusTrap' import { useOverlayRegistry } from '@helpwave/hightide-utils/hooks' import { useTransitionState } from '../../../hooks/useTransitionState' -import { PropsUtil } from '../../../utils/propsUtil' -import { Portal } from '../../utils/Portal' +import { Portal } from '../Portal' import { useDrawerContext } from './DrawerContext' import type { DrawerAligment } from './Drawer' import { ReactUtils } from '@helpwave/hightide-utils/utils' import clsx from 'clsx' +import { PropsUtil } from '../../../utils/propsUtil' + export type DrawerContainerProps = HTMLAttributes & { alignment: DrawerAligment, containerClassName?: string, @@ -62,8 +63,7 @@ export const DrawerContainer = forwardRef(
            setOpen(false)} - className={backgroundClassName} - data-name="drawer-background" + className={clsx('drawer-background', backgroundClassName)} data-state={transitionState} data-depth={depth} data-alignment={alignment} @@ -85,5 +85,3 @@ export const DrawerContainer = forwardRef( ) }) - - diff --git a/packages/hightide-web/src/components/layout/drawer/DrawerContext.tsx b/packages/hightide-web/src/components/layout/Drawer/DrawerContext.tsx similarity index 100% rename from packages/hightide-web/src/components/layout/drawer/DrawerContext.tsx rename to packages/hightide-web/src/components/layout/Drawer/DrawerContext.tsx diff --git a/packages/hightide-web/src/components/layout/drawer/DrawerRoot.tsx b/packages/hightide-web/src/components/layout/Drawer/DrawerRoot.tsx similarity index 100% rename from packages/hightide-web/src/components/layout/drawer/DrawerRoot.tsx rename to packages/hightide-web/src/components/layout/Drawer/DrawerRoot.tsx diff --git a/packages/hightide-web/src/components/layout/drawer/index.ts b/packages/hightide-web/src/components/layout/Drawer/index.ts similarity index 100% rename from packages/hightide-web/src/components/layout/drawer/index.ts rename to packages/hightide-web/src/components/layout/Drawer/index.ts diff --git a/packages/hightide-web/src/components/layout/Expandable.tsx b/packages/hightide-web/src/components/layout/Expandable.tsx deleted file mode 100644 index 28c82719..00000000 --- a/packages/hightide-web/src/components/layout/Expandable.tsx +++ /dev/null @@ -1,240 +0,0 @@ -import type { Dispatch, HTMLAttributes, ReactNode, SetStateAction } from 'react' -import { useEffect, useRef } from 'react' -import { useState } from 'react' -import { createContext, forwardRef, useCallback, useContext, useId, useMemo } from 'react' -import clsx from 'clsx' -import { Visibility } from './Visibility' -import { ExpansionIcon } from '../display-and-visualization/ExpansionIcon' -import { useTransitionState } from '../../hooks/useTransitionState' -import { useControlledState } from '@helpwave/hightide-utils/hooks' -import { ReactUtils } from '@helpwave/hightide-utils/utils' - -// -// Context -// -type ExpandableContextIdsState = { - root: string, - header: string, - content: string, -} - -type ExpandableContextState = { - ids: ExpandableContextIdsState, - setIds: Dispatch>, - disabled: boolean, - isExpanded: boolean, - toggle: () => void, - setIsExpanded: Dispatch>, -} - -const ExpandableContext = createContext(null) - -function useExpandableContext() { - const context = useContext(ExpandableContext) - if (!context) { - throw new Error('Expandable components must be used within an ExpandableRoot') - } - return context -} - -// -// ExpandableRoot -// - -export type ExpandableRootProps = HTMLAttributes & { - isExpanded?: boolean, - onExpandedChange?: (isExpanded: boolean) => void, - isInitialExpanded?: boolean, - disabled?: boolean, -} - -export const ExpandableRoot = forwardRef(function ExpandableRoot({ - children, - id: providedId, - isExpanded: controlledExpanded, - onExpandedChange, - isInitialExpanded = false, - disabled = false, - ...props -}, ref) { - const generatedId = useId() - const [ids, setIds] = useState({ - root: providedId ?? `expandable-${generatedId}-root`, - header: `expandable-${generatedId}-header`, - content: `expandable-${generatedId}-content` - }) - const [isExpanded, setIsExpanded] = useControlledState({ - value: controlledExpanded, - onValueChange: onExpandedChange, - defaultValue: isInitialExpanded, - }) - - const toggle = useCallback(() => { - if (!disabled) { - setIsExpanded(!isExpanded) - } - }, [disabled, isExpanded, setIsExpanded]) - - const contextValue = useMemo(() => ({ - isExpanded, - toggle, - setIsExpanded, - ids, - setIds, - disabled - }), [isExpanded, toggle, setIsExpanded, ids, disabled]) - - return ( - -
            - {children} -
            -
            - ) -}) - -// -// ExpandableHeader -// - -export type ExpandableHeaderProps = HTMLAttributes & { - isUsingDefaultIcon?: boolean, - triggerProps?: HTMLAttributes, -} - -export const ExpandableHeader = forwardRef(function ExpandableHeader({ - children, - isUsingDefaultIcon = true, - triggerProps, - ...props -}, ref) { - const { isExpanded, toggle, ids, setIds, disabled } = useExpandableContext() - useEffect(() => { - const id = props.id - if (id) { - setIds(prevState => ({ ...prevState, header: id })) - } - }, [props.id, setIds]) - - return ( -
            - - - - -
            - ) -}) - -// -// ExpandableContent -// - -export type ExpandableContentProps = HTMLAttributes & { - forceMount?: boolean, - isClosingOnClick?: boolean, -} - -export const ExpandableContent = forwardRef(function ExpandableContent({ - children, - forceMount = false, - isClosingOnClick = false, - ...props -}, forwardedRef) { - const { isExpanded, ids, setIds, setIsExpanded } = useExpandableContext() - - const ref = useRef(null) - - useEffect(() => { - const id = props.id - if (id) { - setIds(prevState => ({ ...prevState, content: id })) - } - }, [props.id, setIds]) - - const { transitionState } = useTransitionState({ isOpen: isExpanded, ref }) - - return ( -
            { - props.onClick?.(event) - if (isClosingOnClick) { - setIsExpanded(false) - } - }} - > - - {children} - -
            - ) -}) - - -// -// Composite -// -export interface ExpandableProps extends ExpandableRootProps { - trigger: ReactNode, - triggerProps?: Omit, - contentProps?: Omit, - contentExpandedClassName?: string, -} - -export const Expandable = forwardRef(function Expandable({ - children, - trigger, - triggerProps, - contentProps, - contentExpandedClassName, - ...props -}, ref) { - return ( - - - {trigger} - - - {ctx => ( - - {children} - - )} - - - ) -}) \ No newline at end of file diff --git a/packages/hightide-web/src/components/layout/Expandable/Expandable.tsx b/packages/hightide-web/src/components/layout/Expandable/Expandable.tsx new file mode 100644 index 00000000..d28d9942 --- /dev/null +++ b/packages/hightide-web/src/components/layout/Expandable/Expandable.tsx @@ -0,0 +1,14 @@ +import { ExpandableContent } from './ExpandableContent' +import { ExpandableContext } from './ExpandableContext' +import { ExpandableRoot } from './ExpandableRoot' +import { ExpandableTrigger } from './ExpandableTrigger' + +const Expandable = { + Root: ExpandableRoot, + Trigger: ExpandableTrigger, + Content: ExpandableContent, + Context: ExpandableContext, + Consumer: ExpandableContext.Consumer, +} + +export { Expandable } diff --git a/packages/hightide-web/src/components/layout/Expandable/ExpandableContent.tsx b/packages/hightide-web/src/components/layout/Expandable/ExpandableContent.tsx new file mode 100644 index 00000000..23f28683 --- /dev/null +++ b/packages/hightide-web/src/components/layout/Expandable/ExpandableContent.tsx @@ -0,0 +1,47 @@ +import type { HTMLAttributes } from 'react' +import { forwardRef, useEffect } from 'react' +import { Visibility } from '../Visibility' +import { useExpandableContext } from './ExpandableContext' +import clsx from 'clsx' + +export type ExpandableContentProps = HTMLAttributes & { + forceMount?: boolean, + isClosingOnClick?: boolean, +} + +export const ExpandableContent = forwardRef(function ExpandableContent({ + children, + forceMount = false, + isClosingOnClick = false, + id, + onClick, + ...props +}, ref) { + const { isExpanded, ids, setIds, setIsExpanded } = useExpandableContext() + + useEffect(() => { + if (id) { + setIds(prevState => ({ ...prevState, content: id })) + } + }, [id, setIds]) + + return ( +
            { + onClick?.(event) + if (isClosingOnClick) { + setIsExpanded(false) + } + }} + className={clsx('expandable-content', props.className)} + > + + {children} + +
            + ) +}) diff --git a/packages/hightide-web/src/components/layout/Expandable/ExpandableContext.tsx b/packages/hightide-web/src/components/layout/Expandable/ExpandableContext.tsx new file mode 100644 index 00000000..5c6ce0f4 --- /dev/null +++ b/packages/hightide-web/src/components/layout/Expandable/ExpandableContext.tsx @@ -0,0 +1,40 @@ +import type { Dispatch, ReactNode, SetStateAction } from 'react' +import { createContext, useContext } from 'react' + +export type ExpandableContextIdsState = { + root: string, + header: string, + content: string, +} + +export type ExpandableContextState = { + ids: ExpandableContextIdsState, + setIds: Dispatch>, + disabled: boolean, + isExpanded: boolean, + toggle: () => void, + setIsExpanded: Dispatch>, +} + +export const ExpandableContext = createContext(null) + +export function useExpandableContext() { + const context = useContext(ExpandableContext) + if (!context) { + throw new Error('Expandable components must be used within an Expandable.Root') + } + return context +} + +export type ExpandableContextProviderProps = { + value: ExpandableContextState, + children: ReactNode, +} + +export function ExpandableContextProvider({ value, children }: ExpandableContextProviderProps) { + return ( + + {children} + + ) +} diff --git a/packages/hightide-web/src/components/layout/Expandable/ExpandableRoot.tsx b/packages/hightide-web/src/components/layout/Expandable/ExpandableRoot.tsx new file mode 100644 index 00000000..168eed26 --- /dev/null +++ b/packages/hightide-web/src/components/layout/Expandable/ExpandableRoot.tsx @@ -0,0 +1,57 @@ +import type { ReactNode } from 'react' +import { useCallback, useId, useMemo, useState } from 'react' +import { useControlledState } from '@helpwave/hightide-utils/hooks' + +import { ExpandableContextProvider } from './ExpandableContext' +import type { ExpandableContextIdsState } from './ExpandableContext' + +export type ExpandableRootProps = { + children: ReactNode, + id?: string, + isExpanded?: boolean, + onExpandedChange?: (isExpanded: boolean) => void, + isInitialExpanded?: boolean, + disabled?: boolean, +} + +export function ExpandableRoot({ + children, + id: providedId, + isExpanded: controlledExpanded, + onExpandedChange, + isInitialExpanded = false, + disabled = false, +}: ExpandableRootProps) { + const generatedId = useId() + const [ids, setIds] = useState({ + root: providedId ?? `expandable-${generatedId}-root`, + header: `expandable-${generatedId}-header`, + content: `expandable-${generatedId}-content`, + }) + const [isExpanded, setIsExpanded] = useControlledState({ + value: controlledExpanded, + onValueChange: onExpandedChange, + defaultValue: isInitialExpanded, + }) + + const toggle = useCallback(() => { + if (!disabled) { + setIsExpanded(!isExpanded) + } + }, [disabled, isExpanded, setIsExpanded]) + + const contextValue = useMemo(() => ({ + isExpanded, + toggle, + setIsExpanded, + ids, + setIds, + disabled, + }), [isExpanded, toggle, setIsExpanded, ids, disabled]) + + return ( + + {children} + + ) +} diff --git a/packages/hightide-web/src/components/layout/Expandable/ExpandableTrigger.tsx b/packages/hightide-web/src/components/layout/Expandable/ExpandableTrigger.tsx new file mode 100644 index 00000000..b2f285ca --- /dev/null +++ b/packages/hightide-web/src/components/layout/Expandable/ExpandableTrigger.tsx @@ -0,0 +1,44 @@ +import type { ReactNode, RefObject } from 'react' +import { useMemo, useRef } from 'react' +import { BagFunctionUtil } from '@helpwave/hightide-utils/utils' + +import { useExpandableContext } from './ExpandableContext' + +export interface ExpandableTriggerBag { + isExpanded: boolean, + disabled: boolean, + toggle: () => void, + props: { + 'id': string, + 'onClick': () => void, + 'aria-expanded': boolean, + 'aria-controls': string, + 'aria-disabled': true | undefined, + 'ref': RefObject, + }, +} + +export interface ExpandableTriggerProps { + children: (bag: ExpandableTriggerBag) => ReactNode, +} + +export function ExpandableTrigger({ children }: ExpandableTriggerProps) { + const { isExpanded, toggle, ids, disabled } = useExpandableContext() + const ref = useRef(null) + + const bag = useMemo>(() => ({ + isExpanded, + disabled, + toggle, + props: { + 'id': ids.header, + 'onClick': toggle, + 'aria-expanded': isExpanded, + 'aria-controls': ids.content, + 'aria-disabled': disabled || undefined, + 'ref': ref, + }, + }), [disabled, ids.content, ids.header, isExpanded, toggle]) + + return BagFunctionUtil.resolve(children, bag) +} diff --git a/packages/hightide-web/src/components/layout/Expandable/index.ts b/packages/hightide-web/src/components/layout/Expandable/index.ts new file mode 100644 index 00000000..a018ebd1 --- /dev/null +++ b/packages/hightide-web/src/components/layout/Expandable/index.ts @@ -0,0 +1,9 @@ +export { Expandable } from './Expandable' +export { ExpandableRoot } from './ExpandableRoot' +export { ExpandableTrigger } from './ExpandableTrigger' +export { ExpandableContent } from './ExpandableContent' +export { useExpandableContext } from './ExpandableContext' + +export type { ExpandableRootProps } from './ExpandableRoot' +export type { ExpandableTriggerBag, ExpandableTriggerProps } from './ExpandableTrigger' +export type { ExpandableContentProps } from './ExpandableContent' diff --git a/packages/hightide-web/src/components/layout/ExpandableSection/ExpandableSection.tsx b/packages/hightide-web/src/components/layout/ExpandableSection/ExpandableSection.tsx new file mode 100644 index 00000000..5e97f620 --- /dev/null +++ b/packages/hightide-web/src/components/layout/ExpandableSection/ExpandableSection.tsx @@ -0,0 +1,100 @@ +import type { HTMLAttributes, ReactNode } from 'react' +import { forwardRef } from 'react' +import clsx from 'clsx' +import { Expandable } from '../Expandable/Expandable' +import { type ExpandableContentProps } from '../Expandable/ExpandableContent' +import { ExpandableContext, useExpandableContext } from '../Expandable/ExpandableContext' +import type { ExpandableRootProps } from '../Expandable/ExpandableRoot' +import { ExpandableSectionContent } from './ExpandableSectionContent' +import { ExpandableSectionHeader } from './ExpandableSectionHeader' +import type { ExpandableSectionHeaderProps } from './ExpandableSectionHeader' +import { ColoringUtils } from '../../interaction' + +export type ExpandableSectionProps = Omit & Omit, 'children' | 'id'> & { + trigger: ReactNode, + triggerProps?: Omit, + contentProps?: Omit, + contentExpandedClassName?: string, + isUsingDefaultIcon?: boolean, + children?: ReactNode, +} + +const ExpandableSectionFrame = forwardRef>(function ExpandableSectionFrame({ + className, + children, + ...props +}, ref) { + const { isExpanded, disabled } = useExpandableContext() + + return ( +
            + {children} +
            + ) +}) + +export const ExpandableSectionComponent = forwardRef(function ExpandableSection({ + children, + trigger, + triggerProps, + contentProps, + contentExpandedClassName, + isUsingDefaultIcon = true, + isExpanded, + onExpandedChange, + isInitialExpanded, + disabled, + id, + className, + ...props +}, ref) { + return ( + + + + {({ props: openerProps }) => ( + { + openerProps.onClick() + triggerProps?.onClick?.(event) + }} + > + {trigger} + + )} + + + {children} + + + + ) +}) + +export const ExpandableSection = Object.assign(ExpandableSectionComponent, { + SectionFrame: ExpandableSectionFrame, + Header: ExpandableSectionHeader, + Content: ExpandableSectionContent, + Context: ExpandableContext, + Consumer: ExpandableContext.Consumer, +}) diff --git a/packages/hightide-web/src/components/layout/ExpandableSection/ExpandableSectionContent.tsx b/packages/hightide-web/src/components/layout/ExpandableSection/ExpandableSectionContent.tsx new file mode 100644 index 00000000..f037a191 --- /dev/null +++ b/packages/hightide-web/src/components/layout/ExpandableSection/ExpandableSectionContent.tsx @@ -0,0 +1,40 @@ +import type { ReactNode } from 'react' +import { forwardRef, useRef } from 'react' +import clsx from 'clsx' +import { ReactUtils } from '@helpwave/hightide-utils/utils' + +import { useTransitionState } from '../../../hooks/useTransitionState' +import { ExpandableContent } from '../Expandable/ExpandableContent' +import type { ExpandableContentProps } from '../Expandable/ExpandableContent' +import { useExpandableContext } from '../Expandable/ExpandableContext' + +export type ExpandableSectionContentProps = Omit & { + contentExpandedClassName?: string, + children?: ReactNode, +} + +export const ExpandableSectionContent = forwardRef(function ExpandableSectionContent({ + children, + className, + contentExpandedClassName, + ...props +}, forwardedRef) { + const { isExpanded } = useExpandableContext() + const ref = useRef(null) + const { transitionState } = useTransitionState({ isOpen: isExpanded, ref }) + + return ( + + {children} + + ) +}) diff --git a/packages/hightide-web/src/components/layout/ExpandableSection/ExpandableSectionHeader.tsx b/packages/hightide-web/src/components/layout/ExpandableSection/ExpandableSectionHeader.tsx new file mode 100644 index 00000000..2e0a5e37 --- /dev/null +++ b/packages/hightide-web/src/components/layout/ExpandableSection/ExpandableSectionHeader.tsx @@ -0,0 +1,40 @@ +import type { ButtonHTMLAttributes } from 'react' +import clsx from 'clsx' + +import { ColoringUtils } from '../../../utils/coloring' +import { ExpansionIcon } from '../../visualization/ExpansionIcon' +import { useExpandableContext } from '../Expandable/ExpandableContext' + +export type ExpandableSectionHeaderProps = ButtonHTMLAttributes & { + isUsingDefaultIcon?: boolean, +} + +export function ExpandableSectionHeader({ + children, + className, + isUsingDefaultIcon = true, + ...props +}: ExpandableSectionHeaderProps) { + const { isExpanded, disabled } = useExpandableContext() + + return ( + + ) +} diff --git a/packages/hightide-web/src/components/layout/ExpandableSection/index.ts b/packages/hightide-web/src/components/layout/ExpandableSection/index.ts new file mode 100644 index 00000000..005304ea --- /dev/null +++ b/packages/hightide-web/src/components/layout/ExpandableSection/index.ts @@ -0,0 +1,7 @@ +export { ExpandableSection } from './ExpandableSection' +export { ExpandableSectionHeader } from './ExpandableSectionHeader' +export { ExpandableSectionContent } from './ExpandableSectionContent' + +export type { ExpandableSectionProps } from './ExpandableSection' +export type { ExpandableSectionHeaderProps } from './ExpandableSectionHeader' +export type { ExpandableSectionContentProps } from './ExpandableSectionContent' diff --git a/packages/hightide-web/src/components/layout/FAQSection.tsx b/packages/hightide-web/src/components/layout/FAQSection.tsx index fa777d4d..10c96661 100644 --- a/packages/hightide-web/src/components/layout/FAQSection.tsx +++ b/packages/hightide-web/src/components/layout/FAQSection.tsx @@ -1,8 +1,8 @@ import type { ReactNode } from 'react' -import type { ExpandableProps } from './Expandable' -import { ExpandableContent, ExpandableHeader, ExpandableRoot } from './Expandable' +import type { ExpandableSectionProps } from './ExpandableSection' +import { ExpandableSection } from './ExpandableSection' -export type FAQItem = Pick & { +export type FAQItem = Pick & { title: string, content: ReactNode, } @@ -19,16 +19,13 @@ export const FAQSection = ({
              {entries.map(({ title, content, ...restProps }, index) => (
            • - - - {title} - - - {content} - - + {content} +
            • ))}
            diff --git a/packages/hightide-web/src/components/layout/InifiniteScroll.tsx b/packages/hightide-web/src/components/layout/InifiniteScroll.tsx index f6238357..8c8715dc 100644 --- a/packages/hightide-web/src/components/layout/InifiniteScroll.tsx +++ b/packages/hightide-web/src/components/layout/InifiniteScroll.tsx @@ -11,8 +11,9 @@ import { } from 'react' import clsx from 'clsx' import { range } from '@helpwave/hightide-utils/utils' -import { Button } from '../user-interaction/Button' +import { Pressable } from '../interaction/Pressable' import { ChevronDown, ChevronUp } from 'lucide-react' +import { Icon } from '../visualization/Icon' import { Visibility } from './Visibility' export interface InfiniteScrollProps { @@ -123,15 +124,15 @@ export function InfiniteScroll({ style={style} > 0}> - + addToStart()}> + + {visibleItems.map((index) => children(index))} - + addToEnd()}> + +
            ) diff --git a/packages/hightide-web/src/components/layout/Modal/Modal.tsx b/packages/hightide-web/src/components/layout/Modal/Modal.tsx new file mode 100644 index 00000000..01d00dcd --- /dev/null +++ b/packages/hightide-web/src/components/layout/Modal/Modal.tsx @@ -0,0 +1,103 @@ +'use client' + +import type { ForwardedRef, ReactNode } from 'react' +import { forwardRef } from 'react' +import clsx from 'clsx' + +import { ModalBackground } from './ModalBackground' +import type { ModalBackgroundProps } from './ModalBackground' +import { ModalCloseButton } from './ModalCloseButton' +import type { ModalCloseButtonProps } from './ModalCloseButton' +import { ModalContainer } from './ModalContainer' +import type { ModalContainerProps } from './ModalContainer' +import { ModalPanel, type ModalPanelProps, type ModalPosition } from './ModalPanel' +import { ModalContext } from './ModalContext' +import { ModalDescription } from './ModalDescription' +import type { ModalDescriptionProps } from './ModalDescription' +import { ModalOpener } from './ModalOpener' +import { ModalRoot } from './ModalRoot' +import { ModalTitle } from './ModalTitle' +import type { ModalTitleProps } from './ModalTitle' +import { ModalContent, type ModalContentProps } from './ModalContent' + +export type { ModalPosition } + +export type ModalProps = { + isOpen?: boolean, + titleElement: ReactNode, + description: ReactNode, + onClose?: () => void, + position?: ModalPosition, + isClosable?: boolean, + children?: ReactNode, + containerProps?: Omit, + backgroundProps?: ModalBackgroundProps, + panelProps?: Omit, + contentProps?: Omit, + titleProps?: Omit, + descriptionProps?: Omit, + closeButtonProps?: ModalCloseButtonProps, +} + +const ModalComponent = forwardRef(function Modal({ + children, + isOpen, + titleElement, + description, + onClose, + position = 'center', + isClosable = true, + containerProps, + backgroundProps, + panelProps, + contentProps, + titleProps, + descriptionProps, + closeButtonProps, +}, forwardedRef: ForwardedRef) { + return ( + + + + + {titleElement} + + + + {description} + + + {children} + + + + ) +}) + +const Modal = Object.assign(ModalComponent, { + Root: ModalRoot, + Container: ModalContainer, + Background: ModalBackground, + Content: ModalPanel, + Title: ModalTitle, + Description: ModalDescription, + CloseButton: ModalCloseButton, + Opener: ModalOpener, + Context: ModalContext, + Consumer: ModalContext.Consumer, +}) + +export { Modal } diff --git a/packages/hightide-web/src/components/layout/Modal/ModalBackground.tsx b/packages/hightide-web/src/components/layout/Modal/ModalBackground.tsx new file mode 100644 index 00000000..854162c3 --- /dev/null +++ b/packages/hightide-web/src/components/layout/Modal/ModalBackground.tsx @@ -0,0 +1,28 @@ +'use client' + +import type { HTMLAttributes } from 'react' +import clsx from 'clsx' + +import { useModalContext } from './ModalContext' + +export type ModalBackgroundProps = HTMLAttributes + +export function ModalBackground({ + ...props +}: ModalBackgroundProps) { + const context = useModalContext() + if (!context.ids || !context.onClose || context.transitionState === undefined) { + throw new Error('Modal.Background must be used within a Modal.Container') + } + + return ( +
            + ) +} diff --git a/packages/hightide-web/src/components/layout/Modal/ModalCloseButton.tsx b/packages/hightide-web/src/components/layout/Modal/ModalCloseButton.tsx new file mode 100644 index 00000000..cfaf0c5e --- /dev/null +++ b/packages/hightide-web/src/components/layout/Modal/ModalCloseButton.tsx @@ -0,0 +1,37 @@ +'use client' + +import clsx from 'clsx' +import { X } from 'lucide-react' +import { useHightideTranslation } from '@helpwave/hightide-utils/context/translation' + +import { Visibility } from '../Visibility' +import type { IconButtonProps } from '../../interaction/IconButton' +import { IconButton } from '../../interaction/IconButton' +import { useModalContext } from './ModalContext' + +export type ModalCloseButtonProps = IconButtonProps + +export function ModalCloseButton({ + ...props +}: ModalCloseButtonProps) { + const translation = useHightideTranslation() + const context = useModalContext() + if (!context.onClose) { + throw new Error('Modal.CloseButton must be used within a Modal.Container') + } + + return ( + + + + ) +} diff --git a/packages/hightide-web/src/components/layout/Modal/ModalContainer.tsx b/packages/hightide-web/src/components/layout/Modal/ModalContainer.tsx new file mode 100644 index 00000000..bc9f1bae --- /dev/null +++ b/packages/hightide-web/src/components/layout/Modal/ModalContainer.tsx @@ -0,0 +1,119 @@ +'use client' + +import type { HTMLAttributes, SetStateAction } from 'react' +import { useCallback, useContext, useId, useMemo, useRef } from 'react' +import clsx from 'clsx' +import { useStableEvent, useLogOnce, useOverlayRegistry } from '@helpwave/hightide-utils/hooks' + +import { useFocusTrap } from '../../../hooks/focus/useFocusTrap' +import { usePresenceRef } from '../../../hooks/usePresenceRef' +import { useTransitionState } from '../../../hooks/useTransitionState' +import { Portal } from '../Portal' +import { Visibility } from '../Visibility' +import { PropsUtil } from '../../../utils/propsUtil' +import { ModalContext } from './ModalContext' +import type { ModalIds } from './ModalContext' + +export type ModalContainerProps = HTMLAttributes & { + isOpen?: boolean, + onClose?: () => void, + isClosable?: boolean, + hasDescription?: boolean, + contentId?: string, +} + +export function ModalContainer({ + children, + isOpen: isOpenProp, + onClose, + isClosable: isClosableProp, + hasDescription = false, + contentId, + ...props +}: ModalContainerProps) { + const parent = useContext(ModalContext) + const generatedId = useId() + const isOpen = isOpenProp ?? parent?.isOpen ?? false + const isClosable = isClosableProp ?? parent?.isClosable ?? true + const parentSetIsOpen = parent?.setIsOpen + const setIsOpen = useCallback((action: SetStateAction) => { + const next = typeof action === 'function' ? action(isOpen) : action + if (!isClosable && isOpen && !next) { + return + } + parentSetIsOpen?.(next) + }, [isClosable, isOpen, parentSetIsOpen]) + const containerRef = useRef(null) + const onCloseStable = useStableEvent(onClose) + + const ids = useMemo(() => ({ + container: `modal-container-${generatedId}`, + background: `modal-background-${generatedId}`, + content: contentId ?? `modal-content-${generatedId}`, + title: `modal-title-${generatedId}`, + description: `modal-description-${generatedId}`, + }), [contentId, generatedId]) + + const onCloseWrapper = useCallback(() => { + if (!isClosable) { + return + } + onCloseStable() + setIsOpen(false) + }, [isClosable, onCloseStable, setIsOpen]) + + useLogOnce('Modal: onClose should be defined when the modal is closable', isClosable && !onClose && !parent) + + const { isVisible, transitionState } = useTransitionState({ isOpen, ref: containerRef }) + const { refAssignment, isPresent, ref } = usePresenceRef({ + isOpen, + }) + useFocusTrap({ + container: ref, + active: isVisible, + }) + const { zIndex } = useOverlayRegistry({ + isActive: isVisible, + }) + + const contextValue = useMemo(() => ({ + isOpen, + setIsOpen, + isClosable, + ids, + onClose: onCloseWrapper, + transitionState, + hasDescription, + contentRef: ref, + contentRefAssignment: refAssignment, + isContentPresent: isPresent, + }), [hasDescription, ids, isClosable, isOpen, isPresent, onCloseWrapper, ref, refAssignment, setIsOpen, transitionState]) + + return ( + + + +
            { + props.onKeyDown?.(event) + if (event.key !== 'Escape' || event.defaultPrevented) { + return + } + event.preventDefault() + onCloseWrapper() + }} + > + {children} +
            +
            +
            +
            + ) +} diff --git a/packages/hightide-web/src/components/layout/Modal/ModalContent.tsx b/packages/hightide-web/src/components/layout/Modal/ModalContent.tsx new file mode 100644 index 00000000..a23818cc --- /dev/null +++ b/packages/hightide-web/src/components/layout/Modal/ModalContent.tsx @@ -0,0 +1,21 @@ +import type { HTMLAttributes } from 'react' +import { forwardRef } from 'react' +import clsx from 'clsx' + +export type ModalContentProps = HTMLAttributes + +export const ModalContent = forwardRef(function ModalContent({ + children, + className, + ...props +}: ModalContentProps, ref) { + return ( +
            + {children} +
            + ) +}) diff --git a/packages/hightide-web/src/components/layout/Modal/ModalContext.tsx b/packages/hightide-web/src/components/layout/Modal/ModalContext.tsx new file mode 100644 index 00000000..366cca64 --- /dev/null +++ b/packages/hightide-web/src/components/layout/Modal/ModalContext.tsx @@ -0,0 +1,34 @@ +import type { Dispatch, RefObject, SetStateAction } from 'react' +import { createContext, useContext } from 'react' +import type { TransitionState } from '../../../hooks/useTransitionState' + +export type ModalIds = { + container: string, + background: string, + content: string, + title: string, + description: string, +} + +export type ModalContextType = { + isOpen: boolean, + setIsOpen: Dispatch>, + isClosable: boolean, + ids?: ModalIds, + onClose?: () => void, + transitionState?: TransitionState, + hasDescription?: boolean, + contentRef?: RefObject, + contentRefAssignment?: (node: HTMLDivElement | null) => void, + isContentPresent?: boolean, +} + +export const ModalContext = createContext(null) + +export function useModalContext() { + const context = useContext(ModalContext) + if (!context) { + throw new Error('Modal components must be used within a Modal.Root') + } + return context +} \ No newline at end of file diff --git a/packages/hightide-web/src/components/layout/Modal/ModalDescription.tsx b/packages/hightide-web/src/components/layout/Modal/ModalDescription.tsx new file mode 100644 index 00000000..7ca29c98 --- /dev/null +++ b/packages/hightide-web/src/components/layout/Modal/ModalDescription.tsx @@ -0,0 +1,31 @@ +'use client' + +import type { HTMLAttributes } from 'react' +import clsx from 'clsx' + +import { Visibility } from '../Visibility' +import { useModalContext } from './ModalContext' + +export type ModalDescriptionProps = HTMLAttributes + +export function ModalDescription({ + children, + ...props +}: ModalDescriptionProps) { + const context = useModalContext() + if (!context.ids) { + throw new Error('Modal.Description must be used within a Modal.Container') + } + + return ( + +
            + {children} +
            +
            + ) +} diff --git a/packages/hightide-web/src/components/layout/Modal/ModalOpener.tsx b/packages/hightide-web/src/components/layout/Modal/ModalOpener.tsx new file mode 100644 index 00000000..f35131f3 --- /dev/null +++ b/packages/hightide-web/src/components/layout/Modal/ModalOpener.tsx @@ -0,0 +1,43 @@ +import { useMemo, type ReactNode } from 'react' +import { BagFunctionUtil } from '@helpwave/hightide-utils/utils' + +import { useModalContext } from './ModalContext' + +export interface ModalOpenerBag { + open: () => void, + close: () => void, + isOpen: boolean, + toggleOpen: () => void, + props: { + 'onClick': () => void, + 'aria-haspopup': 'dialog', + }, +} + +export interface ModalOpenerProps { + children: (props: ModalOpenerBag) => ReactNode, +} + +export function ModalOpener({ children }: ModalOpenerProps) { + const context = useModalContext() + + const bag: ModalOpenerBag = useMemo(() => ({ + open: () => context.setIsOpen(true), + close: () => context.setIsOpen(false), + toggleOpen: () => context.setIsOpen(prev => !prev), + isOpen: context.isOpen, + props: { + 'onClick': () => context.setIsOpen(true), + 'aria-haspopup': 'dialog', + }, + }), [context]) + + return BagFunctionUtil.resolve(children, bag) +} + + +export interface ModalOpenerPassingProps { + 'children'?: React.ReactNode, + 'onClick'?: React.MouseEventHandler, + 'aria-haspopup'?: 'dialog', +} diff --git a/packages/hightide-web/src/components/layout/Modal/ModalPanel.tsx b/packages/hightide-web/src/components/layout/Modal/ModalPanel.tsx new file mode 100644 index 00000000..51521545 --- /dev/null +++ b/packages/hightide-web/src/components/layout/Modal/ModalPanel.tsx @@ -0,0 +1,46 @@ +'use client' + +import type { ForwardedRef, HTMLAttributes } from 'react' +import { forwardRef } from 'react' +import clsx from 'clsx' +import { ReactUtils } from '@helpwave/hightide-utils/utils' + +import { FocusTrap } from '../../interaction/FocusTrap' +import { useModalContext } from './ModalContext' + +export type ModalPosition = 'top' | 'center' | 'none' + +export type ModalPanelProps = HTMLAttributes & { + [key: `data-${string}`]: string | undefined, + position?: ModalPosition, +} + +export const ModalPanel = forwardRef(function ModalContent({ + children, + position = 'center', + ...props +}, forwardedRef: ForwardedRef) { + const context = useModalContext() + if (!context.ids || !context.onClose || context.transitionState === undefined || !context.contentRef || !context.contentRefAssignment) { + throw new Error('Modal.Content must be used within a Modal.Container') + } + + return ( + + + + ) +}) diff --git a/packages/hightide-web/src/components/layout/Modal/ModalRoot.tsx b/packages/hightide-web/src/components/layout/Modal/ModalRoot.tsx new file mode 100644 index 00000000..faefc720 --- /dev/null +++ b/packages/hightide-web/src/components/layout/Modal/ModalRoot.tsx @@ -0,0 +1,38 @@ +import type { PropsWithChildren, SetStateAction } from 'react' +import { useCallback } from 'react' +import { useControlledState } from '@helpwave/hightide-utils/hooks' +import { ModalContext } from './ModalContext' + +export interface ModalRootProps extends PropsWithChildren { + isOpen?: boolean, + onIsOpenChange?: (isOpen: boolean) => void, + initialIsOpen?: boolean, + isClosable?: boolean, +} + +export function ModalRoot({ + children, + isOpen: controlledIsOpen, + onIsOpenChange, + initialIsOpen = false, + isClosable = true, +}: ModalRootProps) { + const [isOpen, setIsOpen] = useControlledState({ + value: controlledIsOpen, + onValueChange: onIsOpenChange, + defaultValue: initialIsOpen, + }) + const setIsOpenGuarded = useCallback((action: SetStateAction) => { + const next = typeof action === 'function' ? action(isOpen) : action + if (!isClosable && isOpen && !next) { + return + } + setIsOpen(next) + }, [isClosable, isOpen, setIsOpen]) + + return ( + + {children} + + ) +} \ No newline at end of file diff --git a/packages/hightide-web/src/components/layout/Modal/ModalTitle.tsx b/packages/hightide-web/src/components/layout/Modal/ModalTitle.tsx new file mode 100644 index 00000000..f8952969 --- /dev/null +++ b/packages/hightide-web/src/components/layout/Modal/ModalTitle.tsx @@ -0,0 +1,28 @@ +'use client' + +import type { HTMLAttributes } from 'react' +import clsx from 'clsx' + +import { useModalContext } from './ModalContext' + +export type ModalTitleProps = HTMLAttributes + +export function ModalTitle({ + children, + ...props +}: ModalTitleProps) { + const context = useModalContext() + if (!context.ids) { + throw new Error('Modal.Title must be used within a Modal.Container') + } + + return ( +
            + {children} +
            + ) +} diff --git a/packages/hightide-web/src/components/layout/Modal/index.ts b/packages/hightide-web/src/components/layout/Modal/index.ts new file mode 100644 index 00000000..e683c238 --- /dev/null +++ b/packages/hightide-web/src/components/layout/Modal/index.ts @@ -0,0 +1,23 @@ +export { Modal } from './Modal' +export type { ModalProps, ModalPosition } from './Modal' +export { ModalContainer } from './ModalContainer' +export type { ModalContainerProps } from './ModalContainer' +export { ModalBackground } from './ModalBackground' +export type { ModalBackgroundProps } from './ModalBackground' +export { ModalPanel } from './ModalPanel' +export type { ModalPanelProps } from './ModalPanel' +export { ModalContent } from './ModalContent' +export type { ModalContentProps } from './ModalContent' +export { ModalTitle } from './ModalTitle' +export type { ModalTitleProps } from './ModalTitle' +export { ModalDescription } from './ModalDescription' +export type { ModalDescriptionProps } from './ModalDescription' +export { ModalCloseButton } from './ModalCloseButton' +export type { ModalCloseButtonProps } from './ModalCloseButton' +export { ModalContext, useModalContext } from './ModalContext' +export type { ModalContextType, ModalIds } from './ModalContext' +export { ModalOpener } from './ModalOpener' +export type { ModalOpenerBag, ModalOpenerPassingProps, ModalOpenerProps } from './ModalOpener' +export { ModalRoot } from './ModalRoot' +export type { ModalRootProps } from './ModalRoot' +export * from './premade' diff --git a/packages/hightide-web/src/components/layout/dialog/premade/ConfirmDialog.tsx b/packages/hightide-web/src/components/layout/Modal/premade/ConfirmModal.tsx similarity index 67% rename from packages/hightide-web/src/components/layout/dialog/premade/ConfirmDialog.tsx rename to packages/hightide-web/src/components/layout/Modal/premade/ConfirmModal.tsx index eacce9de..848fe230 100644 --- a/packages/hightide-web/src/components/layout/dialog/premade/ConfirmDialog.tsx +++ b/packages/hightide-web/src/components/layout/Modal/premade/ConfirmModal.tsx @@ -1,12 +1,12 @@ import type { PropsWithChildren } from 'react' -import type { ButtonColor } from '../../../user-interaction/Button' -import { Button } from '../../../user-interaction/Button' import { useHightideTranslation } from '@helpwave/hightide-utils/context/translation' -import clsx from 'clsx' -import type { DialogProps } from '../Dialog' -import { Dialog } from '../Dialog' -export type ConfirmDialogType = 'positive' | 'negative' | 'neutral' | 'primary' +import type { ButtonColor } from '../../../interaction/Button' +import { Button } from '../../../interaction/Button' +import type { ModalProps } from '../Modal' +import { Modal } from '../Modal' + +export type ConfirmModalType = 'positive' | 'negative' | 'neutral' | 'primary' type ButtonOverwriteType = { text?: string, @@ -14,35 +14,30 @@ type ButtonOverwriteType = { disabled?: boolean, } -export type ConfirmDialogProps = Omit & { +export type ConfirmModalProps = Omit & { isShowingDecline?: boolean, requireAnswer?: boolean, onCancel: () => void, onConfirm: () => void, onDecline?: () => void, - confirmType?: ConfirmDialogType, - /** - * Order: Cancel, Decline, Confirm - */ + confirmType?: ConfirmModalType, buttonOverwrites?: [ButtonOverwriteType, ButtonOverwriteType, ButtonOverwriteType], } -/** - * A Dialog for asking the user for confirmation - */ -export const ConfirmDialog = ({ +export const ConfirmModal = ({ children, onCancel, onConfirm, onDecline, confirmType = 'positive', buttonOverwrites, - className, + isShowingDecline: _isShowingDecline, + requireAnswer: _requireAnswer, ...restProps -}: PropsWithChildren) => { +}: PropsWithChildren) => { const translation = useHightideTranslation() - const mapping: Record = { + const mapping: Record = { neutral: 'neutral', negative: 'negative', positive: 'positive', @@ -50,13 +45,17 @@ export const ConfirmDialog = ({ } return ( - +
            {children}
            -
            +
            {onCancel && ( )}
            -
            + ) } diff --git a/packages/hightide-web/src/components/layout/dialog/premade/DiscardChangesDialog.tsx b/packages/hightide-web/src/components/layout/Modal/premade/DiscardChangesModal.tsx similarity index 53% rename from packages/hightide-web/src/components/layout/dialog/premade/DiscardChangesDialog.tsx rename to packages/hightide-web/src/components/layout/Modal/premade/DiscardChangesModal.tsx index 423326f4..f1354786 100644 --- a/packages/hightide-web/src/components/layout/dialog/premade/DiscardChangesDialog.tsx +++ b/packages/hightide-web/src/components/layout/Modal/premade/DiscardChangesModal.tsx @@ -1,21 +1,22 @@ import type { PropsWithChildren, ReactNode } from 'react' import { useHightideTranslation } from '@helpwave/hightide-utils/context/translation' -import type { ConfirmDialogProps } from './ConfirmDialog' -import { ConfirmDialog } from './ConfirmDialog' -type DiscardChangesDialogProps = - Omit +import type { ConfirmModalProps } from './ConfirmModal' +import { ConfirmModal } from './ConfirmModal' + +type DiscardChangesModalProps = + Omit & { - isShowingDecline?: boolean, - requireAnswer?: boolean, - onCancel: () => void, - onSave: () => void, - onDontSave: () => void, - titleOverwrite?: ReactNode, - descriptionOverwrite?: ReactNode, -} + isShowingDecline?: boolean, + requireAnswer?: boolean, + onCancel: () => void, + onSave: () => void, + onDontSave: () => void, + titleOverwrite?: ReactNode, + descriptionOverwrite?: ReactNode, + } -export const DiscardChangesDialog = ({ +export const DiscardChangesModal = ({ children, onCancel, onSave, @@ -23,10 +24,10 @@ export const DiscardChangesDialog = ({ titleOverwrite, descriptionOverwrite, ...props -}: PropsWithChildren) => { +}: PropsWithChildren) => { const translation = useHightideTranslation() return ( - {children} - + ) } + +export type { DiscardChangesModalProps } diff --git a/packages/hightide-web/src/components/layout/Modal/premade/InputModal.tsx b/packages/hightide-web/src/components/layout/Modal/premade/InputModal.tsx new file mode 100644 index 00000000..8ede205d --- /dev/null +++ b/packages/hightide-web/src/components/layout/Modal/premade/InputModal.tsx @@ -0,0 +1,23 @@ +import type { TextInputProps } from '../../../data-input/input/TextInput' +import { TextInput } from '../../../data-input/input/TextInput' +import type { ConfirmModalProps } from './ConfirmModal' +import { ConfirmModal } from './ConfirmModal' + +export type InputModalProps = ConfirmModalProps & { + inputs: TextInputProps[], +} + +export const InputModal = ({ + inputs, + buttonOverwrites, + ...props +}: InputModalProps) => { + return ( + + {inputs.map((inputProps, index) => )} + + ) +} diff --git a/packages/hightide-web/src/components/layout/dialog/premade/LanguageDialog.tsx b/packages/hightide-web/src/components/layout/Modal/premade/LanguageModal.tsx similarity index 70% rename from packages/hightide-web/src/components/layout/dialog/premade/LanguageDialog.tsx rename to packages/hightide-web/src/components/layout/Modal/premade/LanguageModal.tsx index a824d2be..5c234c1e 100644 --- a/packages/hightide-web/src/components/layout/dialog/premade/LanguageDialog.tsx +++ b/packages/hightide-web/src/components/layout/Modal/premade/LanguageModal.tsx @@ -1,13 +1,14 @@ import type { PropsWithChildren, ReactNode } from 'react' -import type { DialogProps } from '../Dialog' -import { Dialog } from '../Dialog' -import { useLocalization } from '../../../../global-contexts/localization/forward-exports' -import { Button } from '../../../user-interaction/Button' -import type { SelectProps } from '../../../user-interaction/Select/SelectComponent' -import { Select } from '../../../user-interaction/Select/Select' import clsx from 'clsx' import { useHightideTranslation } from '@helpwave/hightide-utils/context/translation' +import { useLocalization } from '../../../../global-contexts/localization/forward-exports' +import { Button } from '../../../interaction/Button' +import type { SelectProps } from '../../../data-input/Select/SelectComponent' +import { Select } from '../../../data-input/Select/Select' +import type { ModalProps } from '../Modal' +import { Modal } from '../Modal' + type LanguageSelectProps = Omit export const LanguageSelect = ({ ...props }: LanguageSelectProps) => { @@ -17,9 +18,9 @@ export const LanguageSelect = ({ ...props }: LanguageSelectProps) => { table.setPageSize(Number(value))} + onValueUpdate={(value) => table.setPageSize(Number(value))} > {pageSizeOptions.map(size => ( diff --git a/packages/hightide-web/src/components/layout/table/TableProvider.tsx b/packages/hightide-web/src/components/layout/Table/TableProvider.tsx similarity index 97% rename from packages/hightide-web/src/components/layout/table/TableProvider.tsx rename to packages/hightide-web/src/components/layout/Table/TableProvider.tsx index 8391cf38..a39febb0 100644 --- a/packages/hightide-web/src/components/layout/table/TableProvider.tsx +++ b/packages/hightide-web/src/components/layout/Table/TableProvider.tsx @@ -1,6 +1,6 @@ import type { ReactNode } from 'react' import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' -import { useEventCallbackStabilizer } from '@helpwave/hightide-utils/hooks' +import { useStableEvent } from '@helpwave/hightide-utils/hooks' import { ColumnSizeUtil } from './columnSizeUtil' import type { ColumnSizingMode, TableColumnDefinitionContextType, TableContainerContextType, TableStateWithoutSizingContextType } from './TableContext' import { TableColumnDefinitionContext, TableContainerContext, TableStateContext, TableStateWithoutSizingContext } from './TableContext' @@ -45,8 +45,8 @@ export const TableProvider = ({ ...tableOptions }: TableProviderProps) => { const translation = useHightideTranslation() - const onRowClickStable = useEventCallbackStabilizer(onRowClick) - const onFillerRowClickStable = useEventCallbackStabilizer(onFillerRowClick) + const onRowClickStable = useStableEvent(onRowClick) + const onFillerRowClickStable = useStableEvent(onFillerRowClick) const [registeredColumns, setRegisteredColumns] = useState[]>([]) const containerRef = useRef(null) diff --git a/packages/hightide-web/src/components/layout/table/TableSortButton.tsx b/packages/hightide-web/src/components/layout/Table/TableSortButton.tsx similarity index 79% rename from packages/hightide-web/src/components/layout/table/TableSortButton.tsx rename to packages/hightide-web/src/components/layout/Table/TableSortButton.tsx index d97f3eb6..bc0e101e 100644 --- a/packages/hightide-web/src/components/layout/table/TableSortButton.tsx +++ b/packages/hightide-web/src/components/layout/Table/TableSortButton.tsx @@ -1,10 +1,12 @@ import { ChevronDown, ChevronsUpDown, ChevronUp } from 'lucide-react' +import { Icon } from '../../visualization/Icon' import clsx from 'clsx' import type { SortDirection } from '@tanstack/react-table' import { Visibility } from '../Visibility' import { useHightideTranslation } from '@helpwave/hightide-utils/context/translation' -import type { IconButtonProps } from '../../user-interaction/IconButton' -import { IconButton } from '../../user-interaction/IconButton' +import type { IconButtonProps } from '../../interaction/IconButton' +import { IconButton } from '../../interaction/IconButton' +import { ColoringUtils } from '../../interaction' type SortingIndexDisplay = { index: number, @@ -31,14 +33,14 @@ export const TableSortButton = ({ }: TableSortButtonProps) => { const translation = useHightideTranslation() - let icon = + let icon = if (sortDirection) { let usedSortDirection = sortDirection if (invert) { usedSortDirection = usedSortDirection === 'desc' ? 'asc' : 'desc' } icon = usedSortDirection === 'asc' ? - () : () + () : () } const parsedIndex = sortingIndexDisplay?.index ?? -1 @@ -61,7 +63,8 @@ export const TableSortButton = ({ >
            {`${parsedIndex}.`}
            diff --git a/packages/hightide-web/src/components/layout/table/TableWithSelectionProvider.tsx b/packages/hightide-web/src/components/layout/Table/TableWithSelectionProvider.tsx similarity index 95% rename from packages/hightide-web/src/components/layout/table/TableWithSelectionProvider.tsx rename to packages/hightide-web/src/components/layout/Table/TableWithSelectionProvider.tsx index 98b43535..eb36813a 100644 --- a/packages/hightide-web/src/components/layout/table/TableWithSelectionProvider.tsx +++ b/packages/hightide-web/src/components/layout/Table/TableWithSelectionProvider.tsx @@ -1,4 +1,4 @@ -import { Checkbox } from '../../user-interaction/Checkbox' +import { Checkbox } from '../../data-input/Checkbox' import { FillerCell } from './FillerCell' import type { TableProviderProps } from './TableProvider' import { TableProvider } from './TableProvider' @@ -36,7 +36,7 @@ export const TableWithSelectionProvider = ({ { + onValueUpdate={value => { const newValue = !!value table?.toggleAllRowsSelected(newValue) }} @@ -52,7 +52,7 @@ export const TableWithSelectionProvider = ({ ) }, diff --git a/packages/hightide-web/src/components/layout/table/VirtualizedTableBody.tsx b/packages/hightide-web/src/components/layout/Table/VirtualizedTableBody.tsx similarity index 93% rename from packages/hightide-web/src/components/layout/table/VirtualizedTableBody.tsx rename to packages/hightide-web/src/components/layout/Table/VirtualizedTableBody.tsx index 4c0c1c73..db9e63f1 100644 --- a/packages/hightide-web/src/components/layout/table/VirtualizedTableBody.tsx +++ b/packages/hightide-web/src/components/layout/Table/VirtualizedTableBody.tsx @@ -3,9 +3,10 @@ import type { Cell, Row, Table } from '@tanstack/react-table' import { flexRender } from '@tanstack/react-table' import clsx from 'clsx' import { useTableContainerContext, useTableStateWithoutSizingContext } from './TableContext' -import { PropsUtil } from '../../../utils/propsUtil' import { BagFunctionUtil } from '@helpwave/hightide-utils/utils' import { range } from '@helpwave/hightide-utils/utils' +import { ColoringUtils } from '../../../utils/coloring' +import { PropsUtil } from '../../../utils/propsUtil' import { FillerCell } from './FillerCell' import { useVirtualizedRows } from '../virtualization/useVirtualizedRows' import type { VirtualizationScroll } from '../virtualization/virtualizationScroll' @@ -45,11 +46,10 @@ const VirtualizedTableRow = memo(({ ref={measureRef} onClick={onRowClick ? () => onRowClick(row, table) : undefined} data-clickable={PropsUtil.dataAttributes.bool(!!onRowClick)} - data-name="table-body-row" - className={className} + className={clsx('table-body-row', className)} > {cells.map(cell => ( - + {flexRender(cell.column.columnDef.cell, cell.getContext())} ))} @@ -105,9 +105,9 @@ export const VirtualizedTableBody = ({ return ( {range(rowCount).map((_, index) => ( - + {table.getVisibleLeafColumns().map((col, colIndex) => ( - + {fillerRowCell(col.id, table) ?? } ))} diff --git a/packages/hightide-web/src/components/layout/table/columnSizeUtil.ts b/packages/hightide-web/src/components/layout/Table/columnSizeUtil.ts similarity index 100% rename from packages/hightide-web/src/components/layout/table/columnSizeUtil.ts rename to packages/hightide-web/src/components/layout/Table/columnSizeUtil.ts diff --git a/packages/hightide-web/src/components/layout/table/index.ts b/packages/hightide-web/src/components/layout/Table/index.ts similarity index 100% rename from packages/hightide-web/src/components/layout/table/index.ts rename to packages/hightide-web/src/components/layout/Table/index.ts diff --git a/packages/hightide-web/src/components/layout/table/placeholderColumn.ts b/packages/hightide-web/src/components/layout/Table/placeholderColumn.ts similarity index 100% rename from packages/hightide-web/src/components/layout/table/placeholderColumn.ts rename to packages/hightide-web/src/components/layout/Table/placeholderColumn.ts diff --git a/packages/hightide-web/src/components/layout/table/types.ts b/packages/hightide-web/src/components/layout/Table/types.ts similarity index 100% rename from packages/hightide-web/src/components/layout/table/types.ts rename to packages/hightide-web/src/components/layout/Table/types.ts diff --git a/packages/hightide-web/src/components/layout/table/useNaturalColumnWidthLock.ts b/packages/hightide-web/src/components/layout/Table/useNaturalColumnWidthLock.ts similarity index 98% rename from packages/hightide-web/src/components/layout/table/useNaturalColumnWidthLock.ts rename to packages/hightide-web/src/components/layout/Table/useNaturalColumnWidthLock.ts index 16564af0..75a23edf 100644 --- a/packages/hightide-web/src/components/layout/table/useNaturalColumnWidthLock.ts +++ b/packages/hightide-web/src/components/layout/Table/useNaturalColumnWidthLock.ts @@ -37,7 +37,7 @@ export function useNaturalColumnWidthLock({ if (element.getBoundingClientRect().width === 0) return if (getComputedStyle(element).tableLayout !== 'auto') return - const headerCells = element.querySelectorAll('th[data-name="table-header-cell"]') + const headerCells = element.querySelectorAll('th.table-header-cell') if (headerCells.length !== columns.length) return const measured: Record = {} columns.forEach((column, index) => { diff --git a/packages/hightide-web/src/components/layout/TextImage.tsx b/packages/hightide-web/src/components/layout/TextImage.tsx index 50a7d853..0d461638 100644 --- a/packages/hightide-web/src/components/layout/TextImage.tsx +++ b/packages/hightide-web/src/components/layout/TextImage.tsx @@ -1,6 +1,6 @@ import clsx from 'clsx' import { useHightideTranslation } from '@helpwave/hightide-utils/context/translation' -import { Button } from '../user-interaction/Button' +import { Button } from '../interaction/Button' type TextImageColor = 'primary' | 'secondary' | 'dark' @@ -65,7 +65,7 @@ export const TextImage = ({
            + ) +} diff --git a/packages/hightide-web/src/components/layout/list/ListItem.tsx b/packages/hightide-web/src/components/layout/list/ListItem.tsx new file mode 100644 index 00000000..43c599a0 --- /dev/null +++ b/packages/hightide-web/src/components/layout/list/ListItem.tsx @@ -0,0 +1,40 @@ +import type { HTMLAttributes } from 'react' +import clsx from 'clsx' + +import { ColoringUtils } from '../../../utils/coloring' +import { ListItemContent } from './ListItemContent' +import type { ListItemColor, ListItemContentOrder, ListItemContentProps } from './ListItemTypes' + +export type ListItemProps = Omit, 'title' | 'content' | 'color'> & Omit & { + contentOrder?: ListItemContentOrder, + color?: ListItemColor, +} + +export function ListItem({ + title, + subtitle, + content, + contentOrder = 'subtitleFirst', + leading, + trailing, + color, + className, + ...props +}: ListItemProps) { + return ( +
            + +
            + ) +} diff --git a/packages/hightide-web/src/components/layout/list/ListItemContent.tsx b/packages/hightide-web/src/components/layout/list/ListItemContent.tsx new file mode 100644 index 00000000..8aeba24e --- /dev/null +++ b/packages/hightide-web/src/components/layout/list/ListItemContent.tsx @@ -0,0 +1,36 @@ +import type { ListItemContentProps } from './ListItemTypes' + +export function ListItemContent({ + title, + subtitle, + content, + contentOrder = 'titleFirst', + leading, + trailing, +}: ListItemContentProps) { + const titleNode = title != null + ? {title} + : null + const subtitleNode = subtitle != null + ? {subtitle} + : null + const text = content != null + ? content + : contentOrder === 'subtitleFirst' + ? <>{subtitleNode}{titleNode} + : <>{titleNode}{subtitleNode} + + return ( + <> + {leading != null && ( + {leading} + )} + + {text} + + {trailing != null && ( + {trailing} + )} + + ) +} diff --git a/packages/hightide-web/src/components/layout/list/ListItemTypes.ts b/packages/hightide-web/src/components/layout/list/ListItemTypes.ts new file mode 100644 index 00000000..c3ed2106 --- /dev/null +++ b/packages/hightide-web/src/components/layout/list/ListItemTypes.ts @@ -0,0 +1,14 @@ +import type { ReactNode } from 'react' + +export type ListItemColor = 'primary' | 'secondary' | 'positive' | 'warning' | 'negative' | 'neutral' + +export type ListItemContentOrder = 'titleFirst' | 'subtitleFirst' + +export type ListItemContentProps = { + title?: ReactNode, + subtitle?: ReactNode, + content?: ReactNode, + contentOrder?: ListItemContentOrder, + leading?: ReactNode, + trailing?: ReactNode, +} diff --git a/packages/hightide-web/src/components/layout/list/ListNavigationItem.tsx b/packages/hightide-web/src/components/layout/list/ListNavigationItem.tsx new file mode 100644 index 00000000..fad5e097 --- /dev/null +++ b/packages/hightide-web/src/components/layout/list/ListNavigationItem.tsx @@ -0,0 +1,133 @@ +import type { AnchorHTMLAttributes, ButtonHTMLAttributes, ElementType, MouseEvent, ReactNode } from 'react' +import clsx from 'clsx' +import { ChevronRight, ExternalLink } from 'lucide-react' +import { Icon } from '../../visualization/Icon' +import { ColoringUtils } from '../../../utils/coloring' +import { PropsUtil } from '../../../utils/propsUtil' +import { ListItemContent } from './ListItemContent' +import type { ListItemColor, ListItemContentOrder } from './ListItemTypes' + +export type ListNavigationLinkProps = AnchorHTMLAttributes & { + href: string, +} + +const DefaultListNavigationLink: ElementType = 'a' + +type ListNavigationContentProps = { + title?: ReactNode, + subtitle?: ReactNode, + content?: ReactNode, + contentOrder?: ListItemContentOrder, + leading?: ReactNode, + isExternal?: boolean, +} + +function ListNavigationContent({ + title, + subtitle, + content, + contentOrder = 'titleFirst', + leading, + isExternal = false, +}: ListNavigationContentProps) { + return ( + + )} + /> + ) +} + +type ListNavigationSharedProps = ListNavigationContentProps & { + color?: ListItemColor, + disabled?: boolean, + className?: string, +} + +export type ListNavigationItemProps = ListNavigationSharedProps & Omit, 'title' | 'content' | 'color' | 'onClick'> & { + href?: string, + onClick?: (event: MouseEvent) => void, + LinkComponent?: ElementType, +} + +export function ListNavigationItem({ + title, + subtitle, + content, + contentOrder = 'titleFirst', + leading, + isExternal = false, + color, + disabled = false, + className, + href, + LinkComponent = DefaultListNavigationLink, + onClick, + ...props +}: ListNavigationItemProps) { + const itemClassName = clsx('list-navigation-item', className) + const body = ( + + ) + + if (href) { + return ( + { + if (disabled) { + event.preventDefault() + return + } + onClick?.(event) + }} + > + {body} + + ) + } + + return ( + + ) +} diff --git a/packages/hightide-web/src/components/layout/list/index.ts b/packages/hightide-web/src/components/layout/list/index.ts new file mode 100644 index 00000000..f2246eaf --- /dev/null +++ b/packages/hightide-web/src/components/layout/list/index.ts @@ -0,0 +1,7 @@ +export { ListActionItem } from './ListActionItem' +export type { ListActionItemProps } from './ListActionItem' +export { ListItem } from './ListItem' +export type { ListItemProps } from './ListItem' +export type { ListItemColor, ListItemContentOrder } from './ListItemTypes' +export { ListNavigationItem } from './ListNavigationItem' +export type { ListNavigationItemProps, ListNavigationLinkProps } from './ListNavigationItem' diff --git a/packages/hightide-web/src/components/layout/loading/LoadingSpinner.tsx b/packages/hightide-web/src/components/layout/loading/LoadingSpinner.tsx deleted file mode 100644 index 87297060..00000000 --- a/packages/hightide-web/src/components/layout/loading/LoadingSpinner.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { LoaderCircle } from 'lucide-react' -import clsx from 'clsx' - -export type LoadingSpinnerProps = { - className?: string, -} - -export const LoadingSpinner = ({ className }: LoadingSpinnerProps) => { - return ( - - ) -} diff --git a/packages/hightide-web/src/components/layout/loading/index.ts b/packages/hightide-web/src/components/layout/loading/index.ts index 16d9dbbf..7896b74c 100644 --- a/packages/hightide-web/src/components/layout/loading/index.ts +++ b/packages/hightide-web/src/components/layout/loading/index.ts @@ -1,5 +1,2 @@ -export * from './ErrorComponent' export * from './LoadingAndErrorComponent' -export * from './LoadingAnimation' -export * from './LoadingSpinner' export * from './LoadingContainer' diff --git a/packages/hightide-web/src/components/layout/navigation/navigation-menus/VerticalNavigationMenuItem.tsx b/packages/hightide-web/src/components/layout/navigation/navigation-menus/VerticalNavigationMenuItem.tsx deleted file mode 100644 index 96914a94..00000000 --- a/packages/hightide-web/src/components/layout/navigation/navigation-menus/VerticalNavigationMenuItem.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import type { ElementType, HTMLAttributeAnchorTarget, ReactNode } from 'react' -import { useId, useRef } from 'react' -import { ExternalLink } from 'lucide-react' -import { ExpansionIcon } from '../../../display-and-visualization/ExpansionIcon' -import { IconButton } from '../../../user-interaction/IconButton' -import { useNavigationItem } from './NavigationContext' -import type { NavigationItemData } from './types' - -export interface LinkComponentProps { - 'className'?: string, - 'href': string, - 'target'?: HTMLAttributeAnchorTarget | undefined, - 'rel'?: string | undefined, - 'aria-current'?: 'page' | undefined, - 'children'?: ReactNode, -} - -export interface VerticalNavigationMenuItemProps { - id: string, - label: NavigationItemData['label'], - url?: string, - external?: boolean, - items?: NavigationItemData[], - depth?: number, - forceMountDepth?: number, - LinkComponent?: ElementType, -} - -const DefaultLink: ElementType = 'a' - -export function VerticalNavigationMenuItem({ - id, - label, - url, - external = false, - items, - depth = 0, - forceMountDepth = 2, - LinkComponent = DefaultLink, -}: VerticalNavigationMenuItemProps) { - const ref = useRef(null) - const groupId = useId() - const { - expanded, - isActive, - toggleExpansion, - } = useNavigationItem(id) - - const hasChildren = items != null && items.length > 0 - const hasLink = url != null - - const labelContent = hasLink ? ( - -
            - {label} - {external && ( - - )} -
            -
            - ) : ( -
            - {label} -
            - ) - - const interactable = hasLink || hasChildren - const forceMountChildren = depth < forceMountDepth - - return ( -
          • -
            -
            { - if(ref.current?.contains(event.target as Node)) return - if(hasChildren && !hasLink) { - toggleExpansion(id) - } - }} - > - {labelContent} -
            - - {hasChildren && ( - { - toggleExpansion(id) - }} - className="vertical-navigation-toggle" - > - - - )} -
            - - {hasChildren && (expanded || forceMountChildren) && ( - - )} -
          • - ) -} diff --git a/packages/hightide-web/src/components/layout/navigation/navigation-menus/types.ts b/packages/hightide-web/src/components/layout/navigation/navigation-menus/types.ts deleted file mode 100644 index 556c864d..00000000 --- a/packages/hightide-web/src/components/layout/navigation/navigation-menus/types.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { ReactNode } from 'react' -import type { TreeNode } from '@helpwave/hightide-utils/hooks' - -export interface NavigationItemData { - id: string, - label: ReactNode, - url?: string, - external?: boolean, - items?: NavigationItemData[], -} - -export function toTreeNodes(items: ReadonlyArray): TreeNode[] { - return items.map((item) => ({ - id: item.id, - items: item.items != null ? toTreeNodes(item.items) : [], - })) -} diff --git a/packages/hightide-web/src/components/layout/table/FillerCell.tsx b/packages/hightide-web/src/components/layout/table/FillerCell.tsx deleted file mode 100644 index b3e052dd..00000000 --- a/packages/hightide-web/src/components/layout/table/FillerCell.tsx +++ /dev/null @@ -1,18 +0,0 @@ - -import { Minus } from 'lucide-react' -import type { HTMLAttributes } from 'react' - -export type FillerCellProps = HTMLAttributes & { - 'data-name'?: string, -} - -export const FillerCell = ({ ...props }: FillerCellProps) => { - return ( -
            - -
            - ) -} \ No newline at end of file diff --git a/packages/hightide-web/src/components/layout/virtualization/virtualizationScroll.ts b/packages/hightide-web/src/components/layout/virtualization/virtualizationScroll.ts index 5248e6a8..5bc79969 100644 --- a/packages/hightide-web/src/components/layout/virtualization/virtualizationScroll.ts +++ b/packages/hightide-web/src/components/layout/virtualization/virtualizationScroll.ts @@ -35,7 +35,7 @@ export function findScrollableAncestor(element: HTMLElement | null): HTMLElement } /** Marks the {@link AppPage} content area — the scroll container for `'page'` mode. */ -export const APP_PAGE_CONTENT_SELECTOR = '[data-name="app-page-content"]' +export const APP_PAGE_CONTENT_SELECTOR = '.app-page-content' /** * Resolves the scroll container for the `'page'` virtualization mode. Prefers the diff --git a/packages/hightide-web/src/components/user-interaction/properties/CheckboxProperty.tsx b/packages/hightide-web/src/components/properties/CheckboxProperty.tsx similarity index 81% rename from packages/hightide-web/src/components/user-interaction/properties/CheckboxProperty.tsx rename to packages/hightide-web/src/components/properties/CheckboxProperty.tsx index c735842c..f6056573 100644 --- a/packages/hightide-web/src/components/user-interaction/properties/CheckboxProperty.tsx +++ b/packages/hightide-web/src/components/properties/CheckboxProperty.tsx @@ -1,7 +1,8 @@ import { Check } from 'lucide-react' +import { Icon } from '../visualization/Icon' import { PropertyBase, type PropertyField } from './PropertyBase' import { useHightideTranslation } from '@helpwave/hightide-utils/context/translation' -import { Button } from '../Button' +import { Button } from '../interaction/Button' export type CheckboxPropertyProps = PropertyField @@ -23,15 +24,15 @@ export const CheckboxProperty = ({ {...baseProps} hasValue={value !== undefined} readOnly={readOnly} - icon={} + icon={} > {() => (