diff --git a/cspell.jsonc b/cspell.jsonc index 5148b744471..1615409b716 100644 --- a/cspell.jsonc +++ b/cspell.jsonc @@ -304,6 +304,7 @@ "xname", "xstar", "yatki", + "zagjs", "zerion", "zerossl", "zetachain", diff --git a/package.json b/package.json index cd7826b4e1c..164ad70b5bb 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "@graphiql/toolkit": "0.11.3", "@growthbook/growthbook-react": "0.21.0", "@helia/verified-fetch": "2.6.12", + "@internationalized/date": "3.12.2", "@metamask/post-message-stream": "^7.0.0", "@metamask/providers": "^10.2.1", "@monaco-editor/react": "4.7.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8264ab44eff..3f3214c291b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -81,6 +81,9 @@ importers: '@helia/verified-fetch': specifier: 2.6.12 version: 2.6.12(bufferutil@4.1.0)(react-native@0.85.3(@babel/core@7.29.7)(@types/react@19.1.7)(bufferutil@4.1.0)(react@19.1.4)(utf-8-validate@6.0.6))(utf-8-validate@6.0.6) + '@internationalized/date': + specifier: 3.12.2 + version: 3.12.2 '@metamask/post-message-stream': specifier: ^7.0.0 version: 7.0.0 @@ -633,6 +636,24 @@ importers: '@emotion/react': specifier: '>=11.14.0' version: 11.14.0(@types/react@18.3.12)(react@19.1.4) + '@internationalized/date': + specifier: '>=3.12.2' + version: 3.12.2 + '@uidotdev/usehooks': + specifier: '>=2.4.1' + version: 2.4.1(react-dom@19.1.4(react@19.1.4))(react@19.1.4) + d3: + specifier: '>=7.9.0' + version: 7.9.0 + dayjs: + specifier: '>=1.11.21' + version: 1.11.21 + dom-to-image: + specifier: '>=2.6.0' + version: 2.6.0 + es-toolkit: + specifier: '>=1.39.10' + version: 1.39.10 next: specifier: '>=16.2.6' version: 16.2.6(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.57.0)(react-dom@19.1.4(react@19.1.4))(react@19.1.4) @@ -2859,9 +2880,6 @@ packages: cpu: [x64] os: [win32] - '@internationalized/date@3.12.1': - resolution: {integrity: sha512-6IedsVWXyq4P9Tj+TxuU8WGWM70hYLl12nbYU8jkikVpa6WXapFazPUcHUMDMoWftIDE2ILDkFFte6W2nFCkRQ==} - '@internationalized/date@3.12.2': resolution: {integrity: sha512-FY1Y+H64NDs+HAF6omlnWxm3mEpfgaCSWtL5l551ZZfImA+kGjPFgrnJrGjH6lfmLL0g8Z/mBu1R3kufeCp6Jw==} @@ -9554,7 +9572,7 @@ packages: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} gradient-avatar@https://codeload.github.com/blockscout/gradient-avatar/tar.gz/86810368c2581d2dc3aca089dc648336c8e17045: - resolution: {gitHosted: true, tarball: https://codeload.github.com/blockscout/gradient-avatar/tar.gz/86810368c2581d2dc3aca089dc648336c8e17045} + resolution: {gitHosted: true, integrity: sha512-0O9zIUvqAKbiefYuC8lgxRIsCzSD07ectXRligCwEHxLEIP7WKfvDJiyLnXAudI4LGn6MNAkl1BC9/sTGtTTDg==, tarball: https://codeload.github.com/blockscout/gradient-avatar/tar.gz/86810368c2581d2dc3aca089dc648336c8e17045} version: 1.0.3 engines: {node: '>=8'} @@ -17040,10 +17058,6 @@ snapshots: '@img/sharp-win32-x64@0.34.5': optional: true - '@internationalized/date@3.12.1': - dependencies: - '@swc/helpers': 0.5.23 - '@internationalized/date@3.12.2': dependencies: '@swc/helpers': 0.5.23 @@ -17127,7 +17141,7 @@ snapshots: '@jest/schemas': 29.6.3 '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 20.16.7 + '@types/node': 22.12.0 '@types/yargs': 17.0.35 chalk: 4.1.2 @@ -22792,7 +22806,7 @@ snapshots: debug: 4.4.3 globby: 11.1.0 is-glob: 4.0.3 - semver: 7.8.1 + semver: 7.8.4 tsutils: 3.21.0(@typescript/typescript6@6.0.2) optionalDependencies: typescript: '@typescript/typescript6@6.0.2' @@ -22806,7 +22820,7 @@ snapshots: debug: 4.4.3 globby: 11.1.0 is-glob: 4.0.3 - semver: 7.8.1 + semver: 7.8.4 tsutils: 3.21.0(typescript@5.9.3) optionalDependencies: typescript: 5.9.3 @@ -22821,7 +22835,7 @@ snapshots: globby: 11.1.0 is-glob: 4.0.3 minimatch: 9.0.9 - semver: 7.8.1 + semver: 7.8.4 ts-api-utils: 1.4.3(@typescript/typescript6@6.0.2) optionalDependencies: typescript: '@typescript/typescript6@6.0.2' @@ -22851,7 +22865,7 @@ snapshots: '@typescript-eslint/visitor-keys': 8.60.0 debug: 4.4.3 minimatch: 10.2.5 - semver: 7.8.1 + semver: 7.8.4 tinyglobby: 0.2.16 ts-api-utils: 2.5.0(@typescript/typescript6@6.0.2) typescript: '@typescript/typescript6@6.0.2' @@ -25760,7 +25774,7 @@ snapshots: chrome-launcher@0.15.2: dependencies: - '@types/node': 20.16.7 + '@types/node': 22.12.0 escape-string-regexp: 4.0.0 is-wsl: 2.2.0 lighthouse-logger: 1.4.2 @@ -25771,7 +25785,7 @@ snapshots: chromium-edge-launcher@0.3.0: dependencies: - '@types/node': 20.16.7 + '@types/node': 22.12.0 escape-string-regexp: 4.0.0 is-wsl: 2.2.0 lighthouse-logger: 1.4.2 @@ -28671,7 +28685,7 @@ snapshots: jest-util@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 20.16.7 + '@types/node': 22.12.0 chalk: 4.1.2 ci-info: 3.9.0 graceful-fs: 4.2.11 @@ -28688,13 +28702,13 @@ snapshots: jest-worker@27.5.1: dependencies: - '@types/node': 20.16.7 + '@types/node': 22.12.0 merge-stream: 2.0.0 supports-color: 8.1.1 jest-worker@29.7.0: dependencies: - '@types/node': 20.16.7 + '@types/node': 22.12.0 jest-util: 29.7.0 merge-stream: 2.0.0 supports-color: 8.1.1 @@ -30363,7 +30377,7 @@ snapshots: react-aria@3.48.0(react-dom@19.1.4(react@19.1.4))(react@19.1.4): dependencies: - '@internationalized/date': 3.12.1 + '@internationalized/date': 3.12.2 '@internationalized/number': 3.6.6 '@internationalized/string': 3.2.8 '@react-types/shared': 3.34.0(react@19.1.4) @@ -30605,7 +30619,7 @@ snapshots: react-stately@3.46.0(react@19.1.4): dependencies: - '@internationalized/date': 3.12.1 + '@internationalized/date': 3.12.2 '@internationalized/number': 3.6.6 '@internationalized/string': 3.2.8 '@react-types/shared': 3.34.0(react@19.1.4) @@ -31100,7 +31114,7 @@ snapshots: dependencies: '@img/colour': 1.1.0 detect-libc: 2.1.2 - semver: 7.8.1 + semver: 7.8.4 optionalDependencies: '@img/sharp-darwin-arm64': 0.34.5 '@img/sharp-darwin-x64': 0.34.5 diff --git a/public/icons/name.d.ts b/public/icons/name.d.ts index 97e717339ca..f88107f97b9 100644 --- a/public/icons/name.d.ts +++ b/public/icons/name.d.ts @@ -29,6 +29,7 @@ | "brands/tac" | "brands/ton" | "burger" + | "calendar" | "certified" | "check" | "checkered_flag" diff --git a/src/features/contract-audit-reports/components/ContractSubmitAuditForm.tsx b/src/features/contract-audit-reports/components/ContractSubmitAuditForm.tsx index 980af604a94..98c0ca6253d 100644 --- a/src/features/contract-audit-reports/components/ContractSubmitAuditForm.tsx +++ b/src/features/contract-audit-reports/components/ContractSubmitAuditForm.tsx @@ -1,6 +1,8 @@ // SPDX-License-Identifier: LicenseRef-Blockscout +import type { DateValue } from '@chakra-ui/react'; import { VStack } from '@chakra-ui/react'; +import { getLocalTimeZone, toCalendarDate, today } from '@internationalized/date'; import React from 'react'; import type { SubmitHandler } from 'react-hook-form'; import { FormProvider, useForm } from 'react-hook-form'; @@ -10,11 +12,10 @@ import type { operations } from '@blockscout/api-types'; import useApiFetch from 'src/api/hooks/useApiFetch'; import type { ResourceError } from 'src/api/resources'; -import dayjs from 'src/shared/date-and-time/dayjs'; - import { Button } from 'src/toolkit/chakra/button'; import { toaster } from 'src/toolkit/chakra/toaster'; import { FormFieldCheckbox } from 'src/toolkit/components/forms/fields/FormFieldCheckbox'; +import { FormFieldDate } from 'src/toolkit/components/forms/fields/FormFieldDate'; import { FormFieldEmail } from 'src/toolkit/components/forms/fields/FormFieldEmail'; import { FormFieldText } from 'src/toolkit/components/forms/fields/FormFieldText'; import { FormFieldUrl } from 'src/toolkit/components/forms/fields/FormFieldUrl'; @@ -32,7 +33,7 @@ export type Inputs = { project_url: string; audit_company_name: string; audit_report_url: string; - audit_publish_date: string; + audit_publish_date: Array; comment?: string; }; @@ -47,11 +48,18 @@ const ContractSubmitAuditForm = ({ address, onSuccess }: Props) => { const formApi = useForm({ mode: 'onTouched', - defaultValues: { is_project_owner: false }, + defaultValues: { is_project_owner: false, audit_publish_date: [] }, }); const { handleSubmit, formState, setError } = formApi; + const maxDate = React.useMemo(() => today(getLocalTimeZone()), []); + const onFormSubmit: SubmitHandler = React.useCallback(async(data) => { + const [ publishDate ] = data.audit_publish_date; + if (!publishDate) { + return; + } + try { await apiFetch< 'core:contract_security_audits', @@ -61,7 +69,11 @@ const ContractSubmitAuditForm = ({ address, onSuccess }: Props) => { pathParams: { hash: address }, fetchParams: { method: 'POST', - body: data, + body: { + ...data, + // the API expects a date-only string, the picker may carry a time + audit_publish_date: toCalendarDate(publishDate).toString(), + }, }, }); @@ -103,9 +115,9 @@ const ContractSubmitAuditForm = ({ address, onSuccess }: Props) => { name="project_url" required placeholder="Project URL"/> name="audit_company_name" required placeholder="Audit company name"/> name="audit_report_url" required placeholder="Audit report URL"/> - + name="audit_publish_date" - inputProps={{ type: 'date', max: dayjs().format('YYYY-MM-DD') }} + max={ maxDate } required placeholder="Audit publish date" /> diff --git a/src/features/contract-audit-reports/components/__screenshots__/ContractSubmitAuditForm.pw.tsx_default_base-view-1.png b/src/features/contract-audit-reports/components/__screenshots__/ContractSubmitAuditForm.pw.tsx_default_base-view-1.png index 8da8a62f711..720886ca12a 100644 Binary files a/src/features/contract-audit-reports/components/__screenshots__/ContractSubmitAuditForm.pw.tsx_default_base-view-1.png and b/src/features/contract-audit-reports/components/__screenshots__/ContractSubmitAuditForm.pw.tsx_default_base-view-1.png differ diff --git a/src/features/csv-export/components/CsvExport.tsx b/src/features/csv-export/components/CsvExport.tsx index 2cc603cff01..8c102ad5783 100644 --- a/src/features/csv-export/components/CsvExport.tsx +++ b/src/features/csv-export/components/CsvExport.tsx @@ -22,7 +22,6 @@ import { useMultichainContext } from 'src/features/multichain/context'; import config from 'src/config'; import ReCaptcha from 'src/services/re-captcha/ReCaptcha'; import useReCaptcha from 'src/services/re-captcha/useReCaptcha'; -import dayjs from 'src/shared/date-and-time/dayjs'; import getErrorMessage from 'src/shared/errors/get-error-message'; import getErrorObjStatusCode from 'src/shared/errors/get-error-obj-status-code'; import useIsInitialLoading from 'src/shared/hooks/useIsInitialLoading'; @@ -37,6 +36,7 @@ import { downloadBlob } from 'src/toolkit/utils/file'; import { useCsvExportContext } from '../utils/context'; import getFileName from '../utils/get-file-name'; +import serializeFormFields from '../utils/serialize-form-fields'; import type { StorageItem } from '../utils/storage'; import CsvExportDialog from './dialog/CsvExportDialog'; import CsvExportDialogDescription from './dialog/CsvExportDialogDescription'; @@ -101,7 +101,7 @@ const CsvExport = ({ const fetchFactorySync = React.useCallback((data?: FormFields) => { return async(recaptchaToken?: string) => { const url = buildUrl(resourceName, pathParams, { - ...mapValues(data || {}, (value) => dayjs(value).toISOString()), + ...serializeFormFields(data), ...queryParams, }, undefined, chain); @@ -135,7 +135,7 @@ const CsvExport = ({ return apiFetch(resourceName, { pathParams, queryParams: { - ...mapValues(data || {}, (value) => dayjs(value).toISOString()), + ...serializeFormFields(data), ...queryParams, }, chain, @@ -158,7 +158,7 @@ const CsvExport = ({ blob, getFileName({ type, - params: { ...mergedParams, ...data }, + params: { ...mergedParams, ...serializeFormFields(data) }, chainConfig, }), ); @@ -186,7 +186,7 @@ const CsvExport = ({ status: 'pending', type, params: pickBy({ - ...mapValues(data || {}, (value) => dayjs(value).toISOString()), + ...serializeFormFields(data), ...mergedParams, chain_id: chain?.id, }, (value) => value !== '' && value !== undefined && value !== null), diff --git a/src/features/csv-export/components/dialog/CsvExportDialog.tsx b/src/features/csv-export/components/dialog/CsvExportDialog.tsx index db6dfdbe3af..6feab92d10f 100644 --- a/src/features/csv-export/components/dialog/CsvExportDialog.tsx +++ b/src/features/csv-export/components/dialog/CsvExportDialog.tsx @@ -1,13 +1,12 @@ // SPDX-License-Identifier: LicenseRef-Blockscout import { chakra, Flex } from '@chakra-ui/react'; +import { getLocalTimeZone, now } from '@internationalized/date'; import React from 'react'; import { FormProvider, useForm } from 'react-hook-form'; import type { FormFields } from './types'; -import dayjs from 'src/shared/date-and-time/dayjs'; - import { Button } from 'src/toolkit/chakra/button'; import { DialogBody, DialogContent, DialogHeader, DialogRoot } from 'src/toolkit/chakra/dialog'; import type { OnOpenChangeHandler } from 'src/toolkit/hooks/useDisclosure'; @@ -27,8 +26,9 @@ const CsvExportDialog = ({ open, onOpenChange, onFormSubmit, onCancel, children, const formApi = useForm({ mode: 'onBlur', defaultValues: { - from_period: dayjs().subtract(1, 'day').format('YYYY-MM-DDTHH:mm'), - to_period: dayjs().format('YYYY-MM-DDTHH:mm'), + // truncated to whole minutes, matching what the picker itself can express + from_period: [ now(getLocalTimeZone()).set({ second: 0, millisecond: 0 }).subtract({ days: 1 }) ], + to_period: [ now(getLocalTimeZone()).set({ second: 0, millisecond: 0 }) ], }, }); diff --git a/src/features/csv-export/components/dialog/CsvExportFormDateField.tsx b/src/features/csv-export/components/dialog/CsvExportFormDateField.tsx index c44d26961bf..bd40e572d7a 100644 --- a/src/features/csv-export/components/dialog/CsvExportFormDateField.tsx +++ b/src/features/csv-export/components/dialog/CsvExportFormDateField.tsx @@ -1,14 +1,14 @@ // SPDX-License-Identifier: LicenseRef-Blockscout +import type { DateValue } from '@chakra-ui/react'; +import { getLocalTimeZone, parseAbsolute } from '@internationalized/date'; import { capitalize } from 'es-toolkit'; import React from 'react'; import type { UseFormReturn } from 'react-hook-form'; import type { FormFields } from './types'; -import dayjs from 'src/shared/date-and-time/dayjs'; - -import { FormFieldText } from 'src/toolkit/components/forms/fields/FormFieldText'; +import { FormFieldDate } from 'src/toolkit/components/forms/fields/FormFieldDate'; interface Props { formApi: UseFormReturn; @@ -18,18 +18,23 @@ interface Props { const CsvExportFormDateField = ({ formApi, name }: Props) => { const { formState, getValues, trigger } = formApi; - const validate = React.useCallback((newValue: string) => { + const validate = React.useCallback((newValue: Array) => { + const [ date ] = newValue ?? []; + if (!date) { + return; + } + if (name === 'from_period') { - const toValue = getValues('to_period'); - if (toValue && dayjs(newValue) > dayjs(toValue)) { + const [ toDate ] = getValues('to_period') ?? []; + if (toDate && date.compare(toDate) > 0) { return 'Incorrect date'; } if (formState.errors.to_period) { trigger('to_period'); } } else { - const fromValue = getValues('from_period'); - if (fromValue && dayjs(fromValue) > dayjs(newValue)) { + const [ fromDate ] = getValues('from_period') ?? []; + if (fromDate && fromDate.compare(date) > 0) { return 'Incorrect date'; } if (formState.errors.from_period) { @@ -38,13 +43,19 @@ const CsvExportFormDateField = ({ formApi, name }: Props) => { } }, [ formState.errors.from_period, formState.errors.to_period, getValues, name, trigger ]); + const maxDate = React.useMemo(() => parseAbsolute(new Date().toISOString(), getLocalTimeZone()), []); + return ( - + name={ name } - inputProps={{ type: 'datetime-local', max: dayjs().format('YYYY-MM-DDTHH:mm') }} + max={ maxDate } placeholder={ capitalize(name.replace('_period', '')) } required - rules={{ validate }} + withTime + bgColor="dialog.bg" + // a bare function would be lost: FormFieldDate spreads rules.validate into an object + // in order to add its own min/max validator + rules={{ validate: { period: validate } }} /> ); }; diff --git a/src/features/csv-export/components/dialog/types.ts b/src/features/csv-export/components/dialog/types.ts index e48d0392cf6..14d6ccb7ff9 100644 --- a/src/features/csv-export/components/dialog/types.ts +++ b/src/features/csv-export/components/dialog/types.ts @@ -1,6 +1,8 @@ // SPDX-License-Identifier: LicenseRef-Blockscout +import type { DateValue } from '@chakra-ui/react'; + export interface FormFields { - from_period: string; - to_period: string; + from_period: Array; + to_period: Array; } diff --git a/src/features/csv-export/utils/serialize-form-fields.spec.ts b/src/features/csv-export/utils/serialize-form-fields.spec.ts new file mode 100644 index 00000000000..21ce381556a --- /dev/null +++ b/src/features/csv-export/utils/serialize-form-fields.spec.ts @@ -0,0 +1,68 @@ +import { CalendarDate, CalendarDateTime, getLocalTimeZone, parseAbsolute } from '@internationalized/date'; + +import type { FormFields } from '../components/dialog/types'; + +import { describe, it, expect } from 'vitest'; + +import serializeFormFields from './serialize-form-fields'; + +// the local wall clock is what the user picked, so asserting on it keeps the +// expectations independent of the time zone the suite happens to run in +const localFieldsOf = (isoString: string) => { + const parsed = parseAbsolute(isoString, getLocalTimeZone()); + return [ parsed.year, parsed.month, parsed.day, parsed.hour, parsed.minute ]; +}; + +describe('serializeFormFields', () => { + it('returns an empty object when there is no data', () => { + expect(serializeFormFields(undefined)).toEqual({}); + }); + + it('omits fields whose value was cleared', () => { + const data = { + from_period: [], + to_period: [ new CalendarDateTime(2026, 7, 30, 12, 0) ], + } as FormFields; + + expect(Object.keys(serializeFormFields(data))).toEqual([ 'to_period' ]); + }); + + it('returns an empty object when every field was cleared', () => { + expect(serializeFormFields({ from_period: [], to_period: [] } as FormFields)).toEqual({}); + }); + + it('resolves a CalendarDateTime in the local zone, preserving the wall clock', () => { + const data = { from_period: [ new CalendarDateTime(2026, 7, 30, 12, 0) ] } as FormFields; + + const result = serializeFormFields(data).from_period; + + expect(result).toMatch(/Z$/); + expect(localFieldsOf(result)).toEqual([ 2026, 7, 30, 12, 0 ]); + }); + + it('treats a date-only value as local midnight', () => { + const data = { from_period: [ new CalendarDate(2026, 7, 30) ] } as FormFields; + + expect(localFieldsOf(serializeFormFields(data).from_period)).toEqual([ 2026, 7, 30, 0, 0 ]); + }); + + it('keeps the instant of a zoned value regardless of the local zone', () => { + const data = { + from_period: [ parseAbsolute('2026-07-30T09:05:00Z', 'America/New_York') ], + } as FormFields; + + expect(serializeFormFields(data).from_period).toBe('2026-07-30T09:05:00.000Z'); + }); + + it('serializes every populated field', () => { + const data = { + from_period: [ new CalendarDateTime(2026, 7, 29, 8, 30) ], + to_period: [ new CalendarDateTime(2026, 7, 30, 17, 45) ], + } as FormFields; + + const result = serializeFormFields(data); + + expect(localFieldsOf(result.from_period)).toEqual([ 2026, 7, 29, 8, 30 ]); + expect(localFieldsOf(result.to_period)).toEqual([ 2026, 7, 30, 17, 45 ]); + }); +}); diff --git a/src/features/csv-export/utils/serialize-form-fields.ts b/src/features/csv-export/utils/serialize-form-fields.ts new file mode 100644 index 00000000000..5ba45c7da4c --- /dev/null +++ b/src/features/csv-export/utils/serialize-form-fields.ts @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: LicenseRef-Blockscout + +import type { DateValue } from '@chakra-ui/react'; +import { getLocalTimeZone, toZoned } from '@internationalized/date'; + +import type { FormFields } from '../components/dialog/types'; + +// the date picker yields wall-clock values carrying no time zone, so they are resolved +// in the user's zone before the API receives them as absolute timestamps +const toAbsoluteString = (date: DateValue): string => { + const zoned = 'timeZone' in date ? date : toZoned(date, getLocalTimeZone()); + return zoned.toAbsoluteString(); +}; + +export default function serializeFormFields(data?: FormFields): Record { + const result: Record = {}; + + Object.entries(data ?? {}).forEach(([ key, value ]) => { + const [ date ] = value ?? []; + if (date) { + result[key] = toAbsoluteString(date); + } + }); + + return result; +} diff --git a/src/sprite/icons/calendar.svg b/src/sprite/icons/calendar.svg new file mode 100644 index 00000000000..9cb1b258142 --- /dev/null +++ b/src/sprite/icons/calendar.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/toolkit/chakra/close-button.tsx b/src/toolkit/chakra/close-button.tsx index c3613cfa16f..dde28723f4a 100644 --- a/src/toolkit/chakra/close-button.tsx +++ b/src/toolkit/chakra/close-button.tsx @@ -1,6 +1,6 @@ // SPDX-License-Identifier: LicenseRef-Blockscout -import type { ButtonProps } from '@chakra-ui/react'; +import type { ButtonProps, JsxStyleProps } from '@chakra-ui/react'; import { Icon, useRecipe } from '@chakra-ui/react'; import * as React from 'react'; @@ -11,6 +11,7 @@ import { IconButton } from './icon-button'; export interface CloseButtonProps extends Omit { variant?: 'plain'; size?: 'md'; + iconProps?: JsxStyleProps; } export const CloseButton = React.forwardRef< @@ -20,10 +21,11 @@ export const CloseButton = React.forwardRef< const recipe = useRecipe({ recipe: closeButtonRecipe }); const [ recipeProps, restProps ] = recipe.splitVariantProps(props); const styles = recipe(recipeProps); + const { iconProps, ...rest } = restProps; return ( - - { props.children ?? } + + { props.children ?? } ); }); diff --git a/src/toolkit/chakra/date-picker.tsx b/src/toolkit/chakra/date-picker.tsx new file mode 100644 index 00000000000..157ba375928 --- /dev/null +++ b/src/toolkit/chakra/date-picker.tsx @@ -0,0 +1,291 @@ +// SPDX-License-Identifier: LicenseRef-Blockscout + +import type { DateValue } from '@chakra-ui/react'; +import { DatePicker as ChakraDatePicker, HStack, Icon, Portal, useControllableState } from '@chakra-ui/react'; +import { CalendarDate, CalendarDateTime, getLocalTimeZone, isSameDay, now, toCalendarDate, toCalendarDateTime, today } from '@internationalized/date'; +import dayjs from 'dayjs'; +import { padStart } from 'es-toolkit/compat'; +import React from 'react'; + +import ArrowIcon from 'src/sprite/icons/arrows/east-mini.svg'; +import CalendarIcon from 'src/sprite/icons/calendar.svg'; + +import { CloseButton } from './close-button'; +import { Field } from './field'; +import { InputGroup } from './input-group'; +import { TimePicker } from './time-picker'; + +export interface DatePickerValueChangeDetails { + value: Array; + valueAsString: Array; + view: 'day' | 'month' | 'year'; +} + +const DATE_FORMAT = 'MMM D, YYYY'; +const DATE_TIME_FORMAT = 'MMM D, YYYY H:mm'; + +// a ZonedDateTime stringifies with an IANA suffix ("...+02:00[Europe/Madrid]") that dayjs cannot parse, +// so every value is narrowed to a plain calendar date-time before formatting +const toDayjs = (date: DateValue) => dayjs(toCalendarDateTime(date).toString()); + +const format = (date: DateValue) => { + return toDayjs(date).format(DATE_FORMAT); +}; + +const formatWithTime = (date: DateValue) => { + return toDayjs(date).format(DATE_TIME_FORMAT); +}; + +const parse = (value: string): DateValue | undefined => { + const parsed = dayjs(value); + if (!parsed.isValid()) return; + + return new CalendarDate(parsed.year(), parsed.month() + 1, parsed.date()); +}; + +const parseWithTime = (value: string): DateValue | undefined => { + const parsed = dayjs(value); + if (!parsed.isValid()) return; + + return new CalendarDateTime( + parsed.year(), + parsed.month() + 1, + parsed.date(), + parsed.hour(), + parsed.minute(), + ); +}; + +const getTimeParts = (date: DateValue | undefined) => { + if (!date || !('hour' in date)) return { hour: undefined, minute: undefined }; + return { hour: date.hour, minute: date.minute }; +}; + +const isToday = (date: DateValue): boolean => { + return isSameDay(date, today(getLocalTimeZone())); +}; + +const getTime = (date: DateValue): string => { + return toDayjs(date).format('H:mm'); +}; + +// a day can be selectable while a specific time on it is not, so the carried-over +// time is pulled back inside the limits instead of producing an out-of-range value +const clampToLimits = (date: CalendarDateTime, min?: DateValue, max?: DateValue): CalendarDateTime => { + if (min && date.compare(min) < 0) { + return toCalendarDateTime(min); + } + if (max && date.compare(max) > 0) { + return toCalendarDateTime(max); + } + return date; +}; + +const getDefaultDateValue = (withCurrentTime?: boolean): CalendarDateTime => { + const current = now(getLocalTimeZone()); + return new CalendarDateTime( + current.year, + current.month, + current.day, + withCurrentTime ? current.hour : 0, + withCurrentTime ? current.minute : 0, + ); +}; + +export interface DatePickerProps extends ChakraDatePicker.RootProps { + withTime?: boolean; + errorText?: string; +} + +export const DatePicker = React.forwardRef( + function DatePicker({ + placeholder, + withTime, + value: valueProp, + defaultValue, + onValueChange: onValueChangeProp, + min, + max, + disabled, + readOnly, + invalid, + errorText, + required, + bgColor, + ...rest + }, ref) { + + const onValueChange = React.useCallback((value: Array | undefined) => { + const formatValue = withTime ? formatWithTime : format; + onValueChangeProp?.({ value: value ?? [], valueAsString: value?.map(formatValue) ?? [], view: 'day' }); + }, [ onValueChangeProp, withTime ]); + + const [ value, setValue ] = useControllableState | undefined>({ + value: valueProp, + defaultValue: defaultValue, + onChange: onValueChange, + }); + + const handleDateChange = React.useCallback((details: DatePickerValueChangeDetails) => { + const newDate = details.value[0]; + if (!newDate) return setValue([]); + + // without a time picker there is no time to carry over, so the value stays a plain calendar date + if (!withTime) { + return setValue([ new CalendarDate(newDate.year, newDate.month, newDate.day) ]); + } + + setValue((prev) => { + const current = prev?.[0] ?? getDefaultDateValue(isToday(newDate)); + const fromNew = getTimeParts(newDate); + const fromCurrent = getTimeParts(current); + const hour = fromNew.hour ?? fromCurrent.hour; + const minute = fromNew.minute ?? fromCurrent.minute; + + return [ clampToLimits(new CalendarDateTime(newDate.year, newDate.month, newDate.day, hour, minute), min, max) ]; + }); + }, [ setValue, withTime, min, max ]); + + const handleTimeChange = React.useCallback((time: string | undefined) => { + setValue((prev) => { + const current = prev?.[0] ?? getDefaultDateValue(); + + // clearing the time keeps the selected day but drops its time component + if (time === undefined) { + return [ toCalendarDate(current) ]; + } + + const [ hours, minutes ] = time.split(':'); + // a date-only value would silently ignore the time fields, so it is widened first + return [ toCalendarDateTime(current).set({ hour: Number(hours), minute: Number(minutes) }) ]; + }); + }, [ setValue ]); + + const positioning = { + placement: 'bottom-start' as const, + overflowPadding: 4, + sameWidth: true, + ...rest.positioning, + offset: { + mainAxis: 4, + ...rest.positioning?.offset, + }, + }; + + return ( + + + + { (context) => { + const isFilled = context.value.length > 0; + + const endElement = ( + + { isFilled && !readOnly && ( + + + + ) } + + + + + ); + + return ( + + + + + + ); + } } + + + + + + { [ 'day' as const, 'month' as const, 'year' as const ].map((view) => ( + + + + + + + + + + + + + { view === 'day' && ( + <> + + { withTime && (() => { + + const minTime = min && 'hour' in min ? + `${ padStart(min.hour.toString(), 2, '0') }:${ padStart(min.minute.toString(), 2, '0') }` : + undefined; + + const maxTime = max && 'hour' in max ? + `${ padStart(max.hour.toString(), 2, '0') }:${ padStart(max.minute.toString(), 2, '0') }` : + undefined; + + const currentDate = value?.[0]; + const isMinDay = currentDate && min && currentDate.year === min.year && currentDate.month === min.month && currentDate.day === min.day; + const isMaxDay = currentDate && max && currentDate.year === max.year && currentDate.month === max.month && currentDate.day === max.day; + + return ( + + ); + })() } + + ) } + { view === 'month' && } + { view === 'year' && } + + )) } + + + + + ); + }); diff --git a/src/toolkit/chakra/field.tsx b/src/toolkit/chakra/field.tsx index 5264105c7a4..0d4bcd49244 100644 --- a/src/toolkit/chakra/field.tsx +++ b/src/toolkit/chakra/field.tsx @@ -15,11 +15,12 @@ export interface FieldProps extends Omit | React.ReactElement; size?: 'sm' | 'md' | 'lg' | '2xl'; + focusVisible?: boolean; } export const Field = React.forwardRef( function Field(props, ref) { - const { label, children, helperText, errorText, optionalText, ...rest } = props; + const { label, children, helperText, errorText, optionalText, focusVisible, ...rest } = props; // A floating field cannot be without a label. if (rest.floating && label) { @@ -31,6 +32,7 @@ export const Field = React.forwardRef( bgColor: rest.bgColor, disabled: rest.disabled, readOnly: rest.readOnly, + ...(focusVisible ? { 'data-focus-visible': true } : {}), }; const labelElement = ( diff --git a/src/toolkit/chakra/time-picker.tsx b/src/toolkit/chakra/time-picker.tsx new file mode 100644 index 00000000000..c3fb187d788 --- /dev/null +++ b/src/toolkit/chakra/time-picker.tsx @@ -0,0 +1,362 @@ +// SPDX-License-Identifier: LicenseRef-Blockscout + +import { HStack, Icon, VStack, useControllableState } from '@chakra-ui/react'; +import { clamp, delay, range } from 'es-toolkit'; +import { padStart } from 'es-toolkit/compat'; +import React from 'react'; + +import ClockIcon from 'src/sprite/icons/clock-light.svg'; + +import { useDisclosure } from '../hooks/useDisclosure'; +import type { ButtonProps } from './button'; +import { Button } from './button'; +import { CloseButton } from './close-button'; +import type { FieldProps } from './field'; +import { Field } from './field'; +import type { InputProps } from './input'; +import { Input } from './input'; +import { InputGroup } from './input-group'; +import { PopoverBody, PopoverContent, PopoverRoot, PopoverTrigger } from './popover'; + +const BUTTON_HEIGHT = 32; +const GAP_HEIGHT = 8; + +const getLimits = (min?: string, max?: string) => { + if (!min && !max) { + return; + } + + const [ minHour, minMinute ] = min?.split(':').map(Number) ?? [ 0, 0 ]; + const [ maxHour, maxMinute ] = max?.split(':').map(Number) ?? [ 23, 59 ]; + + return { + min: { + hours: minHour, + minutes: minMinute, + }, + max: { + hours: maxHour, + minutes: maxMinute, + }, + }; +}; + +interface IsInLimitsParams { + value: number; + type: 'hours' | 'minutes'; + timeValue: { + hours: number | undefined; + minutes: number | undefined; + }; + limits?: Record<'min' | 'max', { hours: number; minutes: number }>; +} + +const isInLimits = ({ value, type, timeValue, limits }: IsInLimitsParams) => { + if (!limits) { + return true; + } + + if (type === 'hours') { + return value >= limits.min.hours && value <= limits.max.hours; + } + + if (timeValue.hours !== undefined) { + if (timeValue.hours === limits.min.hours) { + return value >= limits.min.minutes; + } + + if (timeValue.hours === limits.max.hours) { + return value <= limits.max.minutes; + } + } + + return true; +}; + +const formatValue = (hours: number, minutes: number) => { + return `${ padStart(hours.toString(), 2, '0') }:${ padStart(minutes.toString(), 2, '0') }`; +}; + +const getDefaultValue = ({ limits, type, timeValue }: Omit) => { + if (!limits) { + return 0; + } + + if (type === 'hours') { + if (timeValue.minutes !== undefined) { + if (timeValue.minutes < limits.min.minutes) { + return clamp(limits.min.hours + 1, limits.min.hours, limits.max.hours); + } + } + return limits.min.hours; + } + + if (timeValue.hours !== undefined) { + if (timeValue.hours === limits.min.hours) { + return limits.min.minutes; + } + } + + return 0; +}; + +interface TimePickerItemButtonProps extends ButtonProps { + value: number; +} + +const TimePickerItemButton = React.forwardRef(({ value, ...props }, ref) => { + return ( + + ); +}); + +export interface TimePickerProps extends Omit { + value?: string; + defaultValue?: string; + onValueChange?: (value: string | undefined) => void; + inputProps?: InputProps; + min?: string; + max?: string; +} + +export const TimePicker = ({ + value, + defaultValue, + onValueChange, + min, + max, + disabled, + readOnly, + inputProps, + ...rest +}: TimePickerProps) => { + + const hoursContainerRef = React.useRef(null); + const minutesContainerRef = React.useRef(null); + + const { open, onOpenChange } = useDisclosure(); + const limits = React.useMemo(() => getLimits(min, max), [ min, max ]); + + const onHoursChange = React.useCallback((hours: number | undefined) => { + const [ , minutes ] = value?.split(':') ?? []; + onValueChange?.(hours !== undefined ? formatValue(hours, Number(minutes ?? 0)) : undefined); + }, [ value, onValueChange ]); + + const [ hours, setHours ] = useControllableState({ + value: value?.split(':')[0] ? Number(value.split(':')[0]) : undefined, + defaultValue: defaultValue?.split(':')[0] ? Number(defaultValue.split(':')[0]) : undefined, + onChange: onHoursChange, + }); + + const onMinutesChange = React.useCallback((minutes: number | undefined) => { + const [ hours ] = value?.split(':') ?? []; + onValueChange?.(minutes !== undefined ? formatValue(Number(hours ?? 0), minutes) : undefined); + }, [ value, onValueChange ]); + + const [ minutes, setMinutes ] = useControllableState({ + value: value?.split(':')[1] ? Number(value.split(':')[1]) : undefined, + defaultValue: defaultValue?.split(':')[1] ? Number(defaultValue.split(':')[1]) : undefined, + onChange: onMinutesChange, + }); + + const scrollToItem = React.useCallback((hours: number | undefined, minutes: number | undefined, behavior: ScrollBehavior = 'instant') => { + hours !== undefined && hoursContainerRef.current?.scrollTo({ + top: hours * (BUTTON_HEIGHT + GAP_HEIGHT), + behavior, + }); + minutes !== undefined && minutesContainerRef.current?.scrollTo({ + top: minutes * (BUTTON_HEIGHT + GAP_HEIGHT), + behavior, + }); + }, []); + + const handleHoursClick = React.useCallback(async(event: React.MouseEvent) => { + const button = event.currentTarget as HTMLButtonElement; + const newValue = Number(button.dataset.value); + if (Number.isNaN(newValue)) { + return; + } + setHours(newValue); + + const defaultValueMinutes = getDefaultValue({ limits, type: 'minutes', timeValue: { hours: newValue, minutes } }); + + if (minutes === undefined) { + scrollToItem(undefined, defaultValueMinutes, 'smooth'); + return; + } + + if (!isInLimits({ + value: minutes, + type: 'minutes', + timeValue: { hours: newValue, minutes }, limits, + })) { + // FIXME: subsequent set state will override the previous one where we set hours + await delay(0); + setMinutes(defaultValueMinutes); + scrollToItem(undefined, defaultValueMinutes, 'smooth'); + } + }, [ limits, minutes, setHours, setMinutes, scrollToItem ]); + + const handleMinutesClick = React.useCallback((event: React.MouseEvent) => { + const button = event.currentTarget as HTMLButtonElement; + const newValue = Number(button.dataset.value); + if (Number.isNaN(newValue)) { + return; + } + setMinutes(newValue); + setHours((prev) => { + if (prev === undefined || !isInLimits({ value: prev, type: 'hours', timeValue: { hours: prev, minutes: newValue }, limits })) { + const defaultValue = getDefaultValue({ limits, type: 'hours', timeValue: { hours: prev ?? 0, minutes: newValue } }); + scrollToItem(defaultValue, undefined, 'smooth'); + return defaultValue; + } + return prev; + }); + }, [ limits, scrollToItem, setHours, setMinutes ]); + + const handleClear = React.useCallback((event: React.MouseEvent) => { + event.stopPropagation(); + setHours(undefined); + setMinutes(undefined); + scrollToItem(0, 0); + }, [ setHours, setMinutes, scrollToItem ]); + + const timeValue = React.useMemo(() => ({ hours, minutes }), [ hours, minutes ]); + + React.useEffect(() => { + if (!open) { + return; + } + + const id = window.requestAnimationFrame(() => { + scrollToItem(hours ?? 0, minutes ?? 0); + }); + + return () => window.cancelAnimationFrame(id); + // scroll to the selected time when the popover is opened + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ open ]); + + React.useEffect(() => { + if (limits) { + if ( + (timeValue.hours !== undefined && !isInLimits({ value: timeValue.hours, type: 'hours', timeValue, limits })) || + (timeValue.minutes !== undefined && !isInLimits({ value: timeValue.minutes, type: 'minutes', timeValue, limits })) + ) { + setHours(undefined); + setMinutes(undefined); + scrollToItem(0, 0); + } + } + }, [ limits, timeValue, setHours, setMinutes, scrollToItem ]); + + const endElement = ( + + { !readOnly && ( + + ) } + + + + + ); + + const invalid = React.useMemo(() => { + if (disabled || readOnly || (!min && !max)) { + return false; + } + + return !isInLimits({ value: hours ?? 0, type: 'hours', timeValue, limits }) || !isInLimits({ value: minutes ?? 0, type: 'minutes', timeValue, limits }); + }, [ disabled, readOnly, min, max, hours, minutes, timeValue, limits ]); + + return ( + + + + + + + + + + + + + { range(0, 24).map(hour => { + return ( + + ); + }) } + + + { range(0, 60).map(minute => { + return ( + + ); + }) } + + + + + + ); +}; diff --git a/src/toolkit/components/forms/fields/FormFieldDate.tsx b/src/toolkit/components/forms/fields/FormFieldDate.tsx new file mode 100644 index 00000000000..d71a3baa744 --- /dev/null +++ b/src/toolkit/components/forms/fields/FormFieldDate.tsx @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: LicenseRef-Blockscout + +import React from 'react'; +import type { Path, FieldValues } from 'react-hook-form'; +import { useController, useFormContext } from 'react-hook-form'; + +import type { FormFieldPropsBase } from './types'; + +import type { DatePickerValueChangeDetails, DatePickerProps } from '../../../chakra/date-picker'; +import { DatePicker } from '../../../chakra/date-picker'; +import { getFormFieldErrorText } from '../utils/getFormFieldErrorText'; +import { dateValidatorFactory } from '../validators/date'; + +export type FormFieldDateProps< + FormFields extends FieldValues, + Name extends Path, +> = FormFieldPropsBase & DatePickerProps; + +const FormFieldDateContent = < + FormFields extends FieldValues, + Name extends Path, +>(props: FormFieldDateProps) => { + const { name, rules: rulesProp, controllerProps, value, onValueChange, ...rest } = props; + + const { control } = useFormContext(); + + const dateValidator = React.useMemo( + () => dateValidatorFactory(rest.min, rest.max), + [ rest.min, rest.max ], + ); + + const rules = React.useMemo( + () => ({ + ...rulesProp, + validate: { + ...rulesProp?.validate, + date: dateValidator, + }, + }), + [ rulesProp, dateValidator ], + ); + + const { field, fieldState, formState } = useController({ + control, + name, + rules: { ...rules, required: rest.required }, + ...controllerProps, + }); + + const isDisabled = formState.isSubmitting; + + const handleChange = React.useCallback((details: DatePickerValueChangeDetails) => { + field.onChange(details.value); + }, [ field ]); + + return ( + + ); +}; + +export const FormFieldDate = React.memo(FormFieldDateContent) as typeof FormFieldDateContent; diff --git a/src/toolkit/components/forms/fields/index.ts b/src/toolkit/components/forms/fields/index.ts index 9789600fc92..26656c33b28 100644 --- a/src/toolkit/components/forms/fields/index.ts +++ b/src/toolkit/components/forms/fields/index.ts @@ -6,6 +6,7 @@ export * from './FormFieldAddress'; export * from './FormFieldCheckbox'; export * from './FormFieldCheckboxGroup'; export * from './FormFieldColor'; +export * from './FormFieldDate'; export * from './FormFieldEmail'; export * from './FormFieldNumber'; export * from './FormFieldRadio'; diff --git a/src/toolkit/components/forms/validators/date.spec.ts b/src/toolkit/components/forms/validators/date.spec.ts new file mode 100644 index 00000000000..a1f2cbf65ff --- /dev/null +++ b/src/toolkit/components/forms/validators/date.spec.ts @@ -0,0 +1,66 @@ +import { CalendarDate, CalendarDateTime, getLocalTimeZone, parseAbsolute } from '@internationalized/date'; + +import { describe, it, expect } from 'vitest'; + +import { dateValidatorFactory } from './date'; + +const MIN = new CalendarDateTime(2026, 7, 20, 10, 0); +const MAX = new CalendarDateTime(2026, 7, 30, 18, 0); + +describe('dateValidatorFactory', () => { + it('passes when neither limit is set', () => { + expect(dateValidatorFactory()([ new CalendarDate(1999, 1, 1) ])).toBe(true); + }); + + it('passes for an undefined value', () => { + expect(dateValidatorFactory(MIN, MAX)(undefined)).toBe(true); + }); + + // a cleared field holds [], which used to dereference value[0] and throw + it('passes for a cleared value instead of throwing', () => { + expect(() => dateValidatorFactory(MIN, MAX)([])).not.toThrow(); + expect(dateValidatorFactory(MIN, MAX)([])).toBe(true); + }); + + it('passes for a value inside the limits', () => { + expect(dateValidatorFactory(MIN, MAX)([ new CalendarDateTime(2026, 7, 25, 9, 0) ])).toBe(true); + }); + + it('passes on both boundaries', () => { + expect(dateValidatorFactory(MIN, MAX)([ MIN ])).toBe(true); + expect(dateValidatorFactory(MIN, MAX)([ MAX ])).toBe(true); + }); + + it('rejects a value before the minimum', () => { + expect(dateValidatorFactory(MIN, MAX)([ new CalendarDateTime(2026, 7, 20, 9, 59) ])) + .toBe('Date is before the minimum date'); + }); + + it('rejects a value after the maximum', () => { + expect(dateValidatorFactory(MIN, MAX)([ new CalendarDateTime(2026, 7, 30, 18, 1) ])) + .toBe('Date is after the maximum date'); + }); + + it('applies a lone minimum and a lone maximum', () => { + expect(dateValidatorFactory(MIN)([ new CalendarDateTime(2026, 1, 1, 0, 0) ])) + .toBe('Date is before the minimum date'); + expect(dateValidatorFactory(undefined, MAX)([ new CalendarDateTime(2027, 1, 1, 0, 0) ])) + .toBe('Date is after the maximum date'); + }); + + it('compares a date-only value at day granularity', () => { + // same day as MAX, so the time on MAX must not push it out of range + expect(dateValidatorFactory(MIN, MAX)([ new CalendarDate(2026, 7, 30) ])).toBe(true); + expect(dateValidatorFactory(MIN, MAX)([ new CalendarDate(2026, 7, 31) ])) + .toBe('Date is after the maximum date'); + }); + + it('compares across value types', () => { + const zonedMax = parseAbsolute(new CalendarDateTime(2026, 7, 30, 18, 0) + .toDate(getLocalTimeZone()).toISOString(), getLocalTimeZone()); + + expect(dateValidatorFactory(undefined, zonedMax)([ new CalendarDateTime(2026, 7, 30, 17, 0) ])).toBe(true); + expect(dateValidatorFactory(undefined, zonedMax)([ new CalendarDateTime(2026, 7, 30, 19, 0) ])) + .toBe('Date is after the maximum date'); + }); +}); diff --git a/src/toolkit/components/forms/validators/date.ts b/src/toolkit/components/forms/validators/date.ts new file mode 100644 index 00000000000..7145733c193 --- /dev/null +++ b/src/toolkit/components/forms/validators/date.ts @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: LicenseRef-Blockscout + +import type { DateValue } from '@chakra-ui/react'; + +export const dateValidatorFactory = (min?: DateValue, max?: DateValue) => (value: Array | undefined) => { + if (!value || (min === undefined && max === undefined)) { + return true; + } + + // a cleared field holds an empty array, which has nothing to compare against + const date = value[0]; + if (!date) { + return true; + } + + if (min && date.compare(min) < 0) { + return 'Date is before the minimum date'; + } + + if (max && date.compare(max) > 0) { + return 'Date is after the maximum date'; + } + + return true; +}; diff --git a/src/toolkit/components/forms/validators/index.ts b/src/toolkit/components/forms/validators/index.ts index e1534f76e00..969a747177c 100644 --- a/src/toolkit/components/forms/validators/index.ts +++ b/src/toolkit/components/forms/validators/index.ts @@ -2,6 +2,7 @@ export * from './address'; export * from './color'; +export * from './date'; export * from './email'; export * from './signature'; export * from './text'; diff --git a/src/toolkit/package/package.json b/src/toolkit/package/package.json index 3c29efe143b..a6864a1275a 100644 --- a/src/toolkit/package/package.json +++ b/src/toolkit/package/package.json @@ -43,6 +43,12 @@ "peerDependencies": { "@chakra-ui/react": ">=3.36.1", "@emotion/react": ">=11.14.0", + "@internationalized/date": ">=3.12.2", + "@uidotdev/usehooks": ">=2.4.1", + "d3": ">=7.9.0", + "dayjs": ">=1.11.21", + "dom-to-image": ">=2.6.0", + "es-toolkit": ">=1.39.10", "next": ">=16.2.6", "next-themes": ">=0.4.4", "react": ">=18.3.1", diff --git a/src/toolkit/package/src/index.ts b/src/toolkit/package/src/index.ts index b470973f1df..2e770d481c8 100644 --- a/src/toolkit/package/src/index.ts +++ b/src/toolkit/package/src/index.ts @@ -10,6 +10,7 @@ export * from '../../chakra/checkbox'; export * from '../../chakra/close-button'; export * from '../../chakra/collapsible'; export * from '../../chakra/color-mode'; +export * from '../../chakra/date-picker'; export * from '../../chakra/dialog'; export * from '../../chakra/drawer'; export * from '../../chakra/empty-state'; @@ -37,6 +38,7 @@ export * from '../../chakra/table'; export * from '../../chakra/tabs'; export * from '../../chakra/tag'; export * from '../../chakra/textarea'; +export * from '../../chakra/time-picker'; export * from '../../chakra/toaster'; export * from '../../chakra/tooltip'; diff --git a/src/toolkit/package/vite.config.ts b/src/toolkit/package/vite.config.ts index 6b6422bfef9..6fcfc2b3691 100644 --- a/src/toolkit/package/vite.config.ts +++ b/src/toolkit/package/vite.config.ts @@ -80,8 +80,10 @@ export default defineConfig({ 'next/router', 'next-themes', 'react-hook-form', - 'es-toolkit', - 'dayjs', + // regexps so subpath imports (es-toolkit/compat, dayjs/plugin/*) stay external too + /^es-toolkit(\/.*)?$/, + /^dayjs(\/.*)?$/, + '@internationalized/date', 'd3', 'dom-to-image', '@uidotdev/usehooks', diff --git a/src/toolkit/pages/design-system/DesignSystem.tsx b/src/toolkit/pages/design-system/DesignSystem.tsx index 99736fc0e4a..39190dc6c4e 100644 --- a/src/toolkit/pages/design-system/DesignSystem.tsx +++ b/src/toolkit/pages/design-system/DesignSystem.tsx @@ -19,6 +19,7 @@ import ClipboardShowcase from './tabs/Clipboard'; import CloseButtonShowcase from './tabs/CloseButton'; import CollapsibleShowcase from './tabs/Collapsible'; import ContentLoaderShowcase from './tabs/ContentLoader'; +import DatePickerShowcase from './tabs/DatePicker'; import DialogShowcase from './tabs/Dialog'; import EmptyStateShowcase from './tabs/EmptyState'; import FieldShowcase from './tabs/Field'; @@ -42,6 +43,7 @@ import TableShowcase from './tabs/Table'; import TabsShowcase from './tabs/Tabs'; import TagShowcase from './tabs/Tag'; import TextareaShowcase from './tabs/Textarea'; +import TimePickerShowcase from './tabs/TimePicker'; import ToastShowcase from './tabs/Toast'; import TooltipShowcase from './tabs/Tooltip'; import ValuesShowcase from './tabs/Values'; @@ -56,6 +58,7 @@ const tabs = [ { label: 'Close button', value: 'close-button', component: }, { label: 'Collapsible', value: 'collapsible', component: }, { label: 'Content loader', value: 'content-loader', component: }, + { label: 'Date picker', value: 'date-picker', component: }, { label: 'Dialog', value: 'dialog', component: }, { label: 'Empty state', value: 'empty-state', component: }, { label: 'Field', value: 'field', component: }, @@ -79,6 +82,7 @@ const tabs = [ { label: 'Tabs', value: 'tabs', component: }, { label: 'Tag', value: 'tag', component: }, { label: 'Textarea', value: 'textarea', component: }, + { label: 'Time picker', value: 'time-picker', component: }, { label: 'Toast', value: 'toast', component: }, { label: 'Tooltip', value: 'tooltip', component: }, { label: 'Values', value: 'values', component: }, diff --git a/src/toolkit/pages/design-system/tabs/DatePicker.pw.tsx b/src/toolkit/pages/design-system/tabs/DatePicker.pw.tsx new file mode 100644 index 00000000000..ec95d0899ed --- /dev/null +++ b/src/toolkit/pages/design-system/tabs/DatePicker.pw.tsx @@ -0,0 +1,14 @@ +import React from 'react'; + +import { TabsRoot } from 'src/toolkit/chakra/tabs'; + +import { test, expect } from 'playwright/lib'; + +import DatePicker from './DatePicker'; + +test('default +@dark-mode', async({ render }) => { + const component = await render(); + await expect(component).toHaveScreenshot(); + await component.locator('input[name="date_of_birth"]').click(); + await expect(component).toHaveScreenshot(); +}); diff --git a/src/toolkit/pages/design-system/tabs/DatePicker.tsx b/src/toolkit/pages/design-system/tabs/DatePicker.tsx new file mode 100644 index 00000000000..da0bcd5280f --- /dev/null +++ b/src/toolkit/pages/design-system/tabs/DatePicker.tsx @@ -0,0 +1,123 @@ +// SPDX-License-Identifier: LicenseRef-Blockscout + +import type { DateValue } from '@chakra-ui/react'; +import { parseDate, Text } from '@chakra-ui/react'; +import { getLocalTimeZone, parseAbsolute } from '@internationalized/date'; +import { delay } from 'es-toolkit'; +import React from 'react'; +import type { SubmitHandler } from 'react-hook-form'; +import { FormProvider, useForm } from 'react-hook-form'; + +import { Button } from 'src/toolkit/chakra/button'; +import { DatePicker } from 'src/toolkit/chakra/date-picker'; +import { toaster } from 'src/toolkit/chakra/toaster'; +import { FormFieldDate } from 'src/toolkit/components/forms/fields/FormFieldDate'; +import { DAY, HOUR, MINUTE, SECOND } from 'src/toolkit/utils/consts'; + +import { Section, Container, SectionHeader, SamplesStack, Sample } from '../parts'; + +interface FormFields { + date_of_birth: Array; +} + +const DatePickerShowcase = () => { + + const [ value, setValue ] = React.useState | undefined>(undefined); + + const handleValueChange = React.useCallback((details: { value: Array | undefined }) => { + setValue(details.value); + }, [ setValue ]); + + const timeZone = getLocalTimeZone(); + + const formApi = useForm({ + defaultValues: { + date_of_birth: [], + }, + mode: 'onBlur', + }); + + const onSubmit: SubmitHandler = React.useCallback(async(formData) => { + await delay(SECOND); + // eslint-disable-next-line no-console + console.log(formData); + toaster.success({ + title: 'Form submitted', + description: `Selected date: ${ formData.date_of_birth.toString() }`, + }); + }, []); + + return ( + +
+ Variants + + + + + + + + +
+
+ Min and max date + + + + + + + + +
+
+ With time selection + + + + { value?.toString() ?? 'No value' } + + +
+
+ Form field + + + +
+ + name="date_of_birth" + placeholder="Select date of birth" + w="400px" + min={ parseAbsolute(new Date(Date.now() - 10 * DAY + HOUR + 42 * MINUTE).toISOString(), timeZone) } + max={ parseAbsolute(new Date().toISOString(), timeZone) } + withTime + required + /> + + +
+
+
+
+
+ ); +}; + +export default React.memo(DatePickerShowcase); diff --git a/src/toolkit/pages/design-system/tabs/TimePicker.pw.tsx b/src/toolkit/pages/design-system/tabs/TimePicker.pw.tsx new file mode 100644 index 00000000000..f69f470f01e --- /dev/null +++ b/src/toolkit/pages/design-system/tabs/TimePicker.pw.tsx @@ -0,0 +1,12 @@ +import React from 'react'; + +import { TabsRoot } from 'src/toolkit/chakra/tabs'; + +import { test, expect } from 'playwright/lib'; + +import TimePicker from './TimePicker'; + +test('default +@dark-mode', async({ render }) => { + const component = await render(); + await expect(component).toHaveScreenshot(); +}); diff --git a/src/toolkit/pages/design-system/tabs/TimePicker.tsx b/src/toolkit/pages/design-system/tabs/TimePicker.tsx new file mode 100644 index 00000000000..b4165a5d932 --- /dev/null +++ b/src/toolkit/pages/design-system/tabs/TimePicker.tsx @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: LicenseRef-Blockscout + +import React from 'react'; + +import { TimePicker } from 'src/toolkit/chakra/time-picker'; + +import { Section, Container, SectionHeader, SamplesStack, Sample } from '../parts'; + +const TimePickerShowcase = () => { + + return ( + +
+ Variants + + + + + + + + +
+
+ Min and max time + + + + + +
+
+ ); +}; + +export default React.memo(TimePickerShowcase); diff --git a/src/toolkit/pages/design-system/tabs/__screenshots__/DatePicker.pw.tsx_dark-color-mode_default-dark-mode-1.png b/src/toolkit/pages/design-system/tabs/__screenshots__/DatePicker.pw.tsx_dark-color-mode_default-dark-mode-1.png new file mode 100644 index 00000000000..7222a558b61 Binary files /dev/null and b/src/toolkit/pages/design-system/tabs/__screenshots__/DatePicker.pw.tsx_dark-color-mode_default-dark-mode-1.png differ diff --git a/src/toolkit/pages/design-system/tabs/__screenshots__/DatePicker.pw.tsx_dark-color-mode_default-dark-mode-2.png b/src/toolkit/pages/design-system/tabs/__screenshots__/DatePicker.pw.tsx_dark-color-mode_default-dark-mode-2.png new file mode 100644 index 00000000000..4573e4c60af Binary files /dev/null and b/src/toolkit/pages/design-system/tabs/__screenshots__/DatePicker.pw.tsx_dark-color-mode_default-dark-mode-2.png differ diff --git a/src/toolkit/pages/design-system/tabs/__screenshots__/DatePicker.pw.tsx_default_default-dark-mode-1.png b/src/toolkit/pages/design-system/tabs/__screenshots__/DatePicker.pw.tsx_default_default-dark-mode-1.png new file mode 100644 index 00000000000..5eb42627f11 Binary files /dev/null and b/src/toolkit/pages/design-system/tabs/__screenshots__/DatePicker.pw.tsx_default_default-dark-mode-1.png differ diff --git a/src/toolkit/pages/design-system/tabs/__screenshots__/DatePicker.pw.tsx_default_default-dark-mode-2.png b/src/toolkit/pages/design-system/tabs/__screenshots__/DatePicker.pw.tsx_default_default-dark-mode-2.png new file mode 100644 index 00000000000..6b01484b30f Binary files /dev/null and b/src/toolkit/pages/design-system/tabs/__screenshots__/DatePicker.pw.tsx_default_default-dark-mode-2.png differ diff --git a/src/toolkit/pages/design-system/tabs/__screenshots__/TimePicker.pw.tsx_dark-color-mode_default-dark-mode-1.png b/src/toolkit/pages/design-system/tabs/__screenshots__/TimePicker.pw.tsx_dark-color-mode_default-dark-mode-1.png new file mode 100644 index 00000000000..9a224d269bf Binary files /dev/null and b/src/toolkit/pages/design-system/tabs/__screenshots__/TimePicker.pw.tsx_dark-color-mode_default-dark-mode-1.png differ diff --git a/src/toolkit/pages/design-system/tabs/__screenshots__/TimePicker.pw.tsx_default_default-dark-mode-1.png b/src/toolkit/pages/design-system/tabs/__screenshots__/TimePicker.pw.tsx_default_default-dark-mode-1.png new file mode 100644 index 00000000000..ffb5556697b Binary files /dev/null and b/src/toolkit/pages/design-system/tabs/__screenshots__/TimePicker.pw.tsx_default_default-dark-mode-1.png differ diff --git a/src/toolkit/theme/foundations/zIndex.ts b/src/toolkit/theme/foundations/zIndex.ts index 00ab6019d2b..b85a80d2461 100644 --- a/src/toolkit/theme/foundations/zIndex.ts +++ b/src/toolkit/theme/foundations/zIndex.ts @@ -12,7 +12,7 @@ export const zIndex = { banner: { value: 1200 }, overlay: { value: 1300 }, modal: { value: 1400 }, - modal2: { value: 14001 }, + modal2: { value: 1401 }, tooltip: { value: 1550 }, // otherwise tooltips will not be visible in modals tooltip2: { value: 1551 }, // for tooltips in tooltips toast: { value: 1700 }, diff --git a/src/toolkit/theme/recipes/date-picker.recipe.ts b/src/toolkit/theme/recipes/date-picker.recipe.ts new file mode 100644 index 00000000000..7866d6e536e --- /dev/null +++ b/src/toolkit/theme/recipes/date-picker.recipe.ts @@ -0,0 +1,351 @@ +// SPDX-License-Identifier: LicenseRef-Blockscout + +import { defineSlotRecipe, defineStyle } from '@chakra-ui/react'; + +import { recipe as inputRecipe } from './input.recipe'; + +// PrevTrigger, NextTrigger +const navTriggerStyle = defineStyle({ + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + boxSize: 'var(--datepicker-nav-trigger-size)', + color: 'icon.primary', + cursor: 'pointer', + _hover: { + color: 'hover', + bg: 'transparent', + }, + _disabled: { + opacity: 'control.disabled', + }, +}); + +export const recipe = defineSlotRecipe({ + className: 'date-picker', + slots: [ + 'root', + 'label', + 'indicatorGroup', + 'control', + 'input', + 'trigger', + 'content', + 'view', + 'viewControl', + 'viewTrigger', + 'prevTrigger', + 'nextTrigger', + 'rangeText', + 'table', + 'tableRow', + 'tableHeader', + 'tableCell', + 'tableCellTrigger', + 'monthSelect', + 'yearSelect', + 'clearTrigger', + ], + base: { + root: { + display: 'flex', + flexDirection: 'column', + gap: '1.5', + width: 'full', + '--datepicker-indicators-offset': 'sizes.3', + _disabled: { + opacity: 0.5, + }, + }, + + label: { + textStyle: 'sm', + fontWeight: 'medium', + }, + + indicatorGroup: { + position: 'absolute', + insetEnd: 'var(--datepicker-indicators-offset)', + top: '50%', + transform: 'translateY(-50%)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + gap: '1', + }, + + control: { + display: 'flex', + alignItems: 'center', + gap: '2', + width: 'full', + position: 'relative', + }, + + input: { + flex: '1', + minWidth: '0', + height: 'var(--datepicker-input-height)', + '--input-height': 'var(--datepicker-input-height)', + px: 'var(--datepicker-input-px)', + textStyle: 'sm', + borderRadius: 'base', + outline: '0', + appearance: 'none', + fontWeight: '500', + _readOnly: { + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, + _disabled: { + cursor: 'not-allowed', + }, + }, + + trigger: { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + width: '6', + height: '6', + color: 'icon.primary', + cursor: 'pointer', + outline: 'none', + _hover: { + color: 'hover', + }, + _disabled: { + cursor: 'not-allowed', + }, + _readOnly: { + cursor: 'not-allowed', + _hover: { + color: 'icon.primary', + pointerEvents: 'none', + }, + }, + }, + + content: { + display: 'flex', + flexDirection: 'column', + gap: '3', + p: '4', + minW: '280px', + maxW: '500px', + bg: 'popover.bg', + borderRadius: 'md', + boxShadow: 'popover', + boxShadowColor: 'colors.popover.shadow', + color: 'text.primary', + '--date-picker-z-index': 'zIndex.modal2', + zIndex: 'calc(var(--date-picker-z-index) + var(--layer-index, 0))', + outline: 'none', + _open: { + animationStyle: 'scale-fade-in', + animationDuration: 'fast', + }, + _closed: { + animationStyle: 'scale-fade-out', + animationDuration: 'faster', + }, + }, + + view: { + display: 'flex', + flexDirection: 'column', + gap: '3', + }, + + viewControl: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: '2', + height: 'var(--datepicker-nav-trigger-size)', + }, + + viewTrigger: { + display: 'inline-flex', + flex: '1', + alignItems: 'center', + justifyContent: 'center', + gap: '1', + py: '1', + px: '2', + cursor: 'pointer', + _hover: { + color: 'hover', + bg: 'transparent', + }, + }, + + prevTrigger: navTriggerStyle, + nextTrigger: navTriggerStyle, + + rangeText: { + textStyle: 'md', + fontWeight: '600', + }, + + table: { + borderCollapse: 'separate', + borderSpacing: '0 8px', + }, + + tableHeader: { + width: 'var(--table-cell-size)', + py: '1', + textStyle: 'md', + fontWeight: '600', + textAlign: 'center', + textTransform: 'uppercase', + color: 'text.primary', + }, + + tableCell: { + py: '0', + textAlign: 'center', + }, + + tableCellTrigger: { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + minWidth: 'var(--table-cell-size)', + minHeight: 'var(--table-cell-size)', + textStyle: 'md', + borderRadius: 'sm', + cursor: 'pointer', + position: 'relative', + _hover: { + color: 'hover', + bg: 'transparent', + }, + '[data-view=month] &, [data-view=year] &': { + width: 'calc(var(--table-cell-size) * 1.75)', + }, + _today: { + color: 'colorPalette.fg', + fontWeight: 'semibold', + textDecoration: 'underline', + textUnderlineOffset: '3px', + textDecorationThickness: '2px', + }, + '&[data-selected]': { + bg: 'selected.option.bg', + color: 'whiteAlpha.900', + _hover: { + bg: 'selected.option.bg', + color: 'whiteAlpha.900', + }, + }, + '&[data-in-range]': { + bg: 'selected.option.bg', + color: 'whiteAlpha.900', + borderRadius: '0', + _hover: { + bg: 'selected.option.bg', + color: 'whiteAlpha.900', + }, + }, + '&[data-in-range][data-selected]': { + bg: 'selected.option.bg', + color: 'whiteAlpha.900', + borderRadius: '0', + _hover: { + bg: 'selected.option.bg', + color: 'whiteAlpha.900', + }, + '&[data-range-start][data-range-end]': { + borderRadius: 'sm', + }, + '&[data-range-start]:not([data-range-end])': { + borderStartRadius: 'sm', + borderEndRadius: '0', + }, + '&[data-range-end]:not([data-range-start])': { + borderEndRadius: 'sm', + borderStartRadius: '0', + }, + }, + _disabled: { + opacity: 'control.disabled', + cursor: 'not-allowed', + }, + }, + + clearTrigger: { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + flexShrink: 0, + textStyle: 'xs', + color: 'icon.secondary', + _hover: { + color: 'hover', + }, + _disabled: { + cursor: 'not-allowed', + }, + }, + }, + + variants: { + size: { + lg: { + root: { + '--datepicker-input-height': '60px', + '--datepicker-input-px': 'sizes.4', + }, + view: { + '--table-cell-size': 'sizes.8', + '--datepicker-nav-trigger-size': 'sizes.8', + '--datepicker-select-height': 'sizes.10', + }, + input: { + textStyle: 'md', + }, + }, + }, + + hideOutsideDays: { + 'true': { + tableCellTrigger: { + '&[data-outside-range]': { + visibility: 'hidden', + }, + }, + }, + }, + + variant: { + outline: { + input: inputRecipe.variants?.variant.outline, + }, + }, + + floating: { + 'true': {}, + }, + }, + + compoundVariants: [ + { + size: 'lg', + floating: true, + css: { + input: { + padding: '24px 10px 8px 16px', + }, + }, + }, + ], + + defaultVariants: { + size: 'lg', + variant: 'outline', + floating: true, + }, +}); diff --git a/src/toolkit/theme/recipes/index.ts b/src/toolkit/theme/recipes/index.ts index d9c6524cebe..15a652282ee 100644 --- a/src/toolkit/theme/recipes/index.ts +++ b/src/toolkit/theme/recipes/index.ts @@ -8,6 +8,7 @@ import { recipe as checkbox } from './checkbox.recipe'; import { recipe as checkmark } from './checkmark.recipe'; import { recipe as closeButton } from './close-button.recipe'; import { recipe as code } from './code.recipe'; +import { recipe as datePicker } from './date-picker.recipe'; import { recipe as dialog } from './dialog.recipe'; import { recipe as drawer } from './drawer.recipe'; import { recipe as emptyState } from './empty-state.recipe'; @@ -56,6 +57,7 @@ export const slotRecipes = { accordion, alert, checkbox, + datePicker, dialog, drawer, emptyState,