mirror of
https://github.com/spacedriveapp/spacedrive.git
synced 2026-05-24 00:09:19 -04:00
[ENG 485] Improve create library dialog (#712)
* Move dialogs into router * Prevent tab focus on show password button * Add className props to radio group * Add password strength meter to password input * Add steps to dialog * Update create library dialog * Update select component * Add select form field * Add advanced options to create lib dialog * Remove spacing * Update checkbox styles * Revert to single page, add checkbox & accordion, remove privacy * Revert "Add className props to radio group" This reverts commite53e53cbdf. * Move schema, switch to camel case * Revert "Add steps to dialog" This reverts commite672e1c472. * Revert "Prevent tab focus on show password button" This reverts commitd42e4dc988. * Add margin * add debounce to password strength * Update select
This commit is contained in:
@@ -1,223 +1,222 @@
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { ArrowsClockwise, Clipboard, Eye, EyeSlash, Info } from 'phosphor-react';
|
||||
import { useState } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { CaretRight } from 'phosphor-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Controller } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Algorithm,
|
||||
HASHING_ALGOS,
|
||||
HashingAlgoSlug,
|
||||
LibraryConfigWrapped,
|
||||
hashingAlgoSlugSchema,
|
||||
useBridgeMutation,
|
||||
usePlausibleEvent
|
||||
} from '@sd/client';
|
||||
import {
|
||||
Button,
|
||||
CheckBox,
|
||||
Dialog,
|
||||
Select,
|
||||
RadixCheckbox,
|
||||
SelectOption,
|
||||
Tooltip,
|
||||
UseDialogProps,
|
||||
forms,
|
||||
useDialog
|
||||
} from '@sd/ui';
|
||||
import { PasswordMeter } from '~/components/PasswordMeter';
|
||||
import { generatePassword } from '~/util';
|
||||
|
||||
const { Input, z, useZodForm } = forms;
|
||||
const { Input, z, useZodForm, PasswordInput, Select } = forms;
|
||||
|
||||
const schema = z.object({
|
||||
name: z.string(),
|
||||
password: z.string(),
|
||||
password_validate: z.string(),
|
||||
algorithm: z.string(),
|
||||
hashing_algorithm: hashingAlgoSlugSchema,
|
||||
share_telemetry: z.boolean()
|
||||
});
|
||||
|
||||
export default (props: UseDialogProps) => {
|
||||
const dialog = useDialog(props);
|
||||
const submitPlausibleEvent = usePlausibleEvent();
|
||||
|
||||
const form = useZodForm({
|
||||
schema,
|
||||
defaultValues: {
|
||||
password: '',
|
||||
algorithm: 'XChaCha20Poly1305',
|
||||
hashing_algorithm: 'Argon2id-s'
|
||||
const schema = z
|
||||
.object({
|
||||
name: z.string().min(1),
|
||||
encryptLibrary: z.boolean(),
|
||||
password: z.string(),
|
||||
passwordValidate: z.string(),
|
||||
algorithm: z.enum(['XChaCha20Poly1305', 'Aes256Gcm']),
|
||||
hashingAlgorithm: hashingAlgoSlugSchema
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.encryptLibrary && !data.password) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['password'],
|
||||
message: 'Password is required'
|
||||
});
|
||||
}
|
||||
if (data.password && data.password !== data.passwordValidate) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['passwordValidate'],
|
||||
message: 'Passwords do not match'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const [showMasterPassword1, setShowMasterPassword1] = useState(false);
|
||||
const [showMasterPassword2, setShowMasterPassword2] = useState(false);
|
||||
const MP1CurrentEyeIcon = showMasterPassword1 ? EyeSlash : Eye;
|
||||
const MP2CurrentEyeIcon = showMasterPassword2 ? EyeSlash : Eye;
|
||||
|
||||
export default (props: UseDialogProps) => {
|
||||
const dialog = useDialog(props);
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const submitPlausibleEvent = usePlausibleEvent();
|
||||
|
||||
const [showAdvancedOptions, setShowAdvancedOptions] = useState(false);
|
||||
|
||||
const createLibrary = useBridgeMutation('library.create', {
|
||||
onSuccess: (library) => {
|
||||
queryClient.setQueryData(['library.list'], (libraries: any) => [
|
||||
...(libraries || []),
|
||||
library
|
||||
]);
|
||||
queryClient.setQueryData(
|
||||
['library.list'],
|
||||
(libraries: LibraryConfigWrapped[] | undefined) => [...(libraries || []), library]
|
||||
);
|
||||
|
||||
submitPlausibleEvent({
|
||||
event: {
|
||||
type: 'libraryCreate'
|
||||
}
|
||||
});
|
||||
|
||||
navigate(`/${library.uuid}/overview`);
|
||||
},
|
||||
onError: (err: any) => {
|
||||
console.error(err);
|
||||
onError: (err) => console.log(err)
|
||||
});
|
||||
|
||||
const form = useZodForm({
|
||||
schema: schema,
|
||||
defaultValues: {
|
||||
encryptLibrary: false,
|
||||
password: '',
|
||||
passwordValidate: '',
|
||||
algorithm: 'XChaCha20Poly1305',
|
||||
hashingAlgorithm: 'Argon2id-s'
|
||||
}
|
||||
});
|
||||
|
||||
const onSubmit = form.handleSubmit(async (data) => {
|
||||
if (data.password !== data.password_validate) {
|
||||
alert('Passwords are not the same');
|
||||
} else {
|
||||
await createLibrary.mutateAsync({
|
||||
...data,
|
||||
algorithm: data.algorithm as Algorithm,
|
||||
hashing_algorithm: HASHING_ALGOS[data.hashing_algorithm],
|
||||
auth: {
|
||||
type: 'Password',
|
||||
value: data.password
|
||||
}
|
||||
});
|
||||
}
|
||||
await createLibrary.mutateAsync({
|
||||
name: data.name,
|
||||
algorithm: data.algorithm,
|
||||
hashing_algorithm: HASHING_ALGOS[data.hashingAlgorithm],
|
||||
auth: {
|
||||
type: 'Password',
|
||||
value: data.encryptLibrary ? data.password : ''
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const encryptLibrary = form.watch('encryptLibrary');
|
||||
|
||||
useEffect(() => {
|
||||
if (showAdvancedOptions) setShowAdvancedOptions(false);
|
||||
}, [encryptLibrary]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
form={form}
|
||||
onSubmit={onSubmit}
|
||||
dialog={dialog}
|
||||
title="Create New Library"
|
||||
description="Choose a name for your new library, you can configure this and more settings from the library settings later on."
|
||||
submitDisabled={!form.formState.isValid}
|
||||
ctaLabel="Create"
|
||||
title="Create New Library"
|
||||
description="Libraries are a secure, on-device database. Your files remain where they are, the Library catalogs them and stores all Spacedrive related data."
|
||||
ctaLabel={form.formState.isSubmitting ? 'Creating library...' : 'Create library'}
|
||||
>
|
||||
<Input
|
||||
label="Library name"
|
||||
placeholder="My Cool Library"
|
||||
className="mt-2"
|
||||
{...form.register('name', { required: true })}
|
||||
/>
|
||||
|
||||
<div className="mt-3 mb-1 flex flex-row items-center">
|
||||
<div className="space-x-2">
|
||||
<CheckBox
|
||||
className="bg-app-selected"
|
||||
defaultChecked={true}
|
||||
{...form.register('share_telemetry', { required: true })}
|
||||
/>
|
||||
</div>
|
||||
<span className="mt-1 text-xs font-medium">Share anonymous usage</span>
|
||||
<Tooltip label="Share completely anonymous telemetry data to help the developers improve the app">
|
||||
<Info className="ml-1.5 h-4 w-4 text-ink-faint" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* TODO: Proper UI for this. Maybe checkbox for encrypted or not and then reveal these fields. Select encrypted by default. */}
|
||||
{/* <span className="text-sm">Make the secret key field empty to skip key setup.</span> */}
|
||||
|
||||
<div className="relative flex flex-col">
|
||||
<p className="mt-2 mb-1 text-center text-[0.95rem] font-bold">Key Manager</p>
|
||||
<div className="my-1 h-[2px] w-full bg-gray-500" />
|
||||
|
||||
<div className="mt-5 space-y-4">
|
||||
<Input
|
||||
label="Master password"
|
||||
placeholder="Password"
|
||||
type={showMasterPassword1 ? 'text' : 'password'}
|
||||
className="mt-2"
|
||||
{...form.register('password')}
|
||||
right={
|
||||
<div className="flex">
|
||||
<Button
|
||||
onClick={() => {
|
||||
const password = generatePassword(32);
|
||||
|
||||
form.setValue('password', password);
|
||||
form.setValue('password_validate', password);
|
||||
|
||||
setShowMasterPassword1(true);
|
||||
setShowMasterPassword2(true);
|
||||
}}
|
||||
size="icon"
|
||||
>
|
||||
<ArrowsClockwise className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(form.watch('password') as string);
|
||||
}}
|
||||
size="icon"
|
||||
>
|
||||
<Clipboard className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setShowMasterPassword1(!showMasterPassword1)}
|
||||
size="icon"
|
||||
>
|
||||
<MP1CurrentEyeIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
{...form.register('name')}
|
||||
label="Library name"
|
||||
placeholder={'e.g. "James\' Library"'}
|
||||
size="md"
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="encryptLibrary"
|
||||
render={({ field }) => (
|
||||
<RadixCheckbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
label="Encrypt Library"
|
||||
name="encryptLibrary"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
{encryptLibrary && (
|
||||
<>
|
||||
<div className="border-b border-app-line" />
|
||||
|
||||
<PasswordInput
|
||||
{...form.register('password')}
|
||||
label="Password"
|
||||
showStrength
|
||||
/>
|
||||
<PasswordInput
|
||||
{...form.register('passwordValidate', {
|
||||
onBlur: () => form.trigger('passwordValidate')
|
||||
})}
|
||||
label="Confirm password"
|
||||
/>
|
||||
|
||||
<div className="rounded-md border border-app-line bg-app-overlay">
|
||||
<Button
|
||||
variant="bare"
|
||||
className={clsx(
|
||||
'flex w-full border-none !p-3',
|
||||
showAdvancedOptions && 'rounded-b-none'
|
||||
)}
|
||||
onClick={() => setShowAdvancedOptions(!showAdvancedOptions)}
|
||||
>
|
||||
Advanced Settings
|
||||
<CaretRight
|
||||
weight="bold"
|
||||
className={clsx(
|
||||
'ml-1 transition',
|
||||
showAdvancedOptions && 'rotate-90'
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
{showAdvancedOptions && (
|
||||
<div className="space-y-4 p-3 pt-0">
|
||||
<div className="h-px bg-app-line" />
|
||||
<Select
|
||||
control={form.control}
|
||||
name="algorithm"
|
||||
label="Algorithm"
|
||||
size="md"
|
||||
className="!mt-3"
|
||||
>
|
||||
<SelectOption value="XChaCha20Poly1305">
|
||||
XChaCha20-Poly1305
|
||||
</SelectOption>
|
||||
<SelectOption value="Aes256Gcm">AES-256-GCM</SelectOption>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
control={form.control}
|
||||
name="hashingAlgorithm"
|
||||
label="Hashing Algorithm"
|
||||
size="md"
|
||||
>
|
||||
<SelectOption value="Argon2id-s">
|
||||
Argon2id (standard)
|
||||
</SelectOption>
|
||||
<SelectOption value="Argon2id-h">
|
||||
Argon2id (hardened)
|
||||
</SelectOption>
|
||||
<SelectOption value="Argon2id-p">
|
||||
Argon2id (paranoid)
|
||||
</SelectOption>
|
||||
<SelectOption value="BalloonBlake3-s">
|
||||
BLAKE3-Balloon (standard)
|
||||
</SelectOption>
|
||||
<SelectOption value="BalloonBlake3-h">
|
||||
BLAKE3-Balloon (hardened)
|
||||
</SelectOption>
|
||||
<SelectOption value="BalloonBlake3-p">
|
||||
BLAKE3-Balloon (paranoid)
|
||||
</SelectOption>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="Master password (again)"
|
||||
placeholder="Password"
|
||||
type={showMasterPassword2 ? 'text' : 'password'}
|
||||
className="mt-2"
|
||||
right={
|
||||
<Button
|
||||
onClick={() => setShowMasterPassword2(!showMasterPassword2)}
|
||||
size="icon"
|
||||
>
|
||||
<MP2CurrentEyeIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
}
|
||||
{...form.register('password_validate')}
|
||||
/>
|
||||
|
||||
<div className="mt-4 mb-3 grid w-full grid-cols-2 gap-4">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-bold">Encryption</span>
|
||||
<Select
|
||||
className="mt-2"
|
||||
value={form.watch('algorithm')}
|
||||
onChange={(e) => form.setValue('algorithm', e)}
|
||||
>
|
||||
<SelectOption value="XChaCha20Poly1305">XChaCha20-Poly1305</SelectOption>
|
||||
<SelectOption value="Aes256Gcm">AES-256-GCM</SelectOption>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-bold">Hashing</span>
|
||||
<Select
|
||||
className="mt-2"
|
||||
value={form.watch('hashing_algorithm')}
|
||||
onChange={(e) => form.setValue('hashing_algorithm', e as HashingAlgoSlug)}
|
||||
>
|
||||
<SelectOption value="Argon2id-s">Argon2id (standard)</SelectOption>
|
||||
<SelectOption value="Argon2id-h">Argon2id (hardened)</SelectOption>
|
||||
<SelectOption value="Argon2id-p">Argon2id (paranoid)</SelectOption>
|
||||
<SelectOption value="BalloonBlake3-s">
|
||||
BLAKE3-Balloon (standard)
|
||||
</SelectOption>
|
||||
<SelectOption value="BalloonBlake3-h">
|
||||
BLAKE3-Balloon (hardened)
|
||||
</SelectOption>
|
||||
<SelectOption value="BalloonBlake3-p">
|
||||
BLAKE3-Balloon (paranoid)
|
||||
</SelectOption>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PasswordMeter password={form.watch('password')} />
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Navigate, Outlet, RouteObject } from 'react-router-dom';
|
||||
import { currentLibraryCache, useCachedLibraries, useInvalidateQuery } from '@sd/client';
|
||||
import { Dialogs } from '@sd/ui';
|
||||
import { useKeybindHandler } from '~/hooks/useKeyboardHandler';
|
||||
import libraryRoutes from './$libraryId';
|
||||
import onboardingRoutes from './onboarding';
|
||||
@@ -23,7 +24,12 @@ const Wrapper = () => {
|
||||
useKeybindHandler();
|
||||
useInvalidateQuery();
|
||||
|
||||
return <Outlet />;
|
||||
return (
|
||||
<>
|
||||
<Outlet />
|
||||
<Dialogs />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// NOTE: all route `Layout`s below should contain
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Form, RadioGroup, useZodForm, z } from '@sd/ui/src/forms';
|
||||
import { OnboardingContainer, OnboardingDescription, OnboardingTitle } from './Layout';
|
||||
import { useUnlockOnboardingScreen } from './Progress';
|
||||
|
||||
const shareTelemetry = RadioGroup.options([
|
||||
export const shareTelemetry = RadioGroup.options([
|
||||
z.literal('share-telemetry'),
|
||||
z.literal('no-telemetry')
|
||||
]).details({
|
||||
|
||||
@@ -9,7 +9,6 @@ import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
import { RouterProvider, RouterProviderProps } from 'react-router-dom';
|
||||
import { useDebugState } from '@sd/client';
|
||||
import { Dialogs } from '@sd/ui';
|
||||
import ErrorFallback from './ErrorFallback';
|
||||
|
||||
export * from './util/keybind';
|
||||
@@ -49,7 +48,6 @@ export const SpacedriveInterface = (props: { router: RouterProviderProps['router
|
||||
<ErrorBoundary FallbackComponent={ErrorFallback}>
|
||||
<Devtools />
|
||||
<RouterProvider router={props.router} />
|
||||
<Dialogs />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -41,7 +41,8 @@
|
||||
"react-loading-icons": "^1.1.0",
|
||||
"react-router-dom": "6.9.0",
|
||||
"react-spring": "^9.5.5",
|
||||
"tailwindcss-radix": "^2.6.0"
|
||||
"tailwindcss-radix": "^2.6.0",
|
||||
"use-debounce": "^9.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.19.3",
|
||||
|
||||
@@ -25,18 +25,18 @@ export interface RadixCheckboxProps extends ComponentProps<typeof Checkbox.Root>
|
||||
|
||||
// TODO: Replace above with this, requires refactor of usage
|
||||
export const RadixCheckbox = (props: RadixCheckboxProps) => (
|
||||
<div className="align-center flex">
|
||||
<div className="flex items-center">
|
||||
<Checkbox.Root
|
||||
className="flex h-[17px] w-[17px] shrink-0 rounded-md bg-app-button"
|
||||
id={props.name}
|
||||
{...props}
|
||||
>
|
||||
<Checkbox.Indicator className="flex h-[17px] w-[17px] items-center justify-center rounded-md bg-accent">
|
||||
<Check weight="bold" />
|
||||
<Check weight="bold" size={14} />
|
||||
</Checkbox.Indicator>
|
||||
</Checkbox.Root>
|
||||
{props.label && (
|
||||
<label className=" ml-2 font-medium" htmlFor={props.name}>
|
||||
<label className="ml-2 text-sm font-medium" htmlFor={props.name}>
|
||||
{props.label}
|
||||
</label>
|
||||
)}
|
||||
|
||||
@@ -1,69 +1,86 @@
|
||||
import { ReactComponent as ChevronDouble } from '@sd/assets/svgs/chevron-double.svg';
|
||||
import * as SelectPrimitive from '@radix-ui/react-select';
|
||||
import * as RS from '@radix-ui/react-select';
|
||||
import { VariantProps, cva } from 'class-variance-authority';
|
||||
import clsx from 'clsx';
|
||||
import { CaretDown, Check } from 'phosphor-react';
|
||||
import { Check } from 'phosphor-react';
|
||||
import { PropsWithChildren } from 'react';
|
||||
|
||||
interface SelectProps {
|
||||
export const selectStyles = cva(
|
||||
[
|
||||
'rounded-md border text-sm flex pl-3 pr-[10px] items-center justify-between',
|
||||
'shadow-sm outline-none transition-all focus:ring-2',
|
||||
'radix-placeholder:text-ink-faint'
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: [
|
||||
'bg-app-input focus:bg-app-focus',
|
||||
'border-app-line focus:border-app-divider/80',
|
||||
'focus:ring-app-selected/30'
|
||||
]
|
||||
},
|
||||
|
||||
size: {
|
||||
sm: 'h-[30px]',
|
||||
md: 'h-[34px]',
|
||||
lg: 'h-[38px]'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'sm'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export interface SelectProps extends VariantProps<typeof selectStyles> {
|
||||
value: string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
className?: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function Select(props: PropsWithChildren<SelectProps>) {
|
||||
return (
|
||||
<SelectPrimitive.Root
|
||||
<RS.Root
|
||||
defaultValue={props.value}
|
||||
value={props.value}
|
||||
onValueChange={props.onChange}
|
||||
disabled={props.disabled}
|
||||
>
|
||||
<SelectPrimitive.Trigger
|
||||
className={clsx(
|
||||
'inline-flex items-center border bg-app-box py-0.5 pl-2',
|
||||
'rounded-md border-app-line shadow shadow-app-shade/10 outline-none',
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
<span className="grow truncate text-left text-xs">
|
||||
<SelectPrimitive.Value />
|
||||
</span>
|
||||
<RS.Trigger className={selectStyles({ size: props.size, className: props.className })}>
|
||||
<RS.Value placeholder={props.placeholder} />
|
||||
<RS.Icon className="ml-2">
|
||||
<ChevronDouble className="text-ink-dull" />
|
||||
</RS.Icon>
|
||||
</RS.Trigger>
|
||||
|
||||
<SelectPrimitive.Icon>
|
||||
<ChevronDouble className="mr-0.5 h-3 w-3 text-ink-dull" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
|
||||
<SelectPrimitive.Portal className="relative">
|
||||
<SelectPrimitive.Content className="absolute z-50 w-full rounded-md border border-app-line bg-app-box p-1 shadow-2xl shadow-app-shade/20 ">
|
||||
<SelectPrimitive.ScrollUpButton className="hidden ">
|
||||
<CaretDown />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
<SelectPrimitive.Viewport>{props.children}</SelectPrimitive.Viewport>
|
||||
<SelectPrimitive.ScrollDownButton className="hidden "></SelectPrimitive.ScrollDownButton>
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
</SelectPrimitive.Root>
|
||||
<RS.Portal>
|
||||
<RS.Content className="z-50 rounded-md border border-app-line bg-app-box shadow-2xl shadow-app-shade/20 ">
|
||||
<RS.Viewport className="p-1">{props.children}</RS.Viewport>
|
||||
</RS.Content>
|
||||
</RS.Portal>
|
||||
</RS.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function SelectOption(props: PropsWithChildren<{ value: string; default?: boolean }>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
className={clsx(
|
||||
'relative flex items-center px-1 py-0.5 pl-6 pr-4 text-xs',
|
||||
'font-sm cursor-pointer select-none rounded text-ink',
|
||||
'hover:bg-accent hover:text-white focus:outline-none radix-disabled:opacity-50 '
|
||||
)}
|
||||
defaultChecked={props.default || false}
|
||||
<RS.Item
|
||||
value={props.value}
|
||||
defaultChecked={props.default}
|
||||
className={clsx(
|
||||
'relative flex h-6 cursor-pointer select-none items-center rounded pl-6 pr-3',
|
||||
'text-sm text-ink radix-highlighted:text-white',
|
||||
'focus:outline-none radix-disabled:opacity-50 radix-highlighted:bg-accent '
|
||||
)}
|
||||
>
|
||||
<SelectPrimitive.ItemText>{props.children}</SelectPrimitive.ItemText>
|
||||
<SelectPrimitive.ItemIndicator className="absolute left-1 inline-flex items-center">
|
||||
<RS.ItemText>{props.children}</RS.ItemText>
|
||||
<RS.ItemIndicator className="absolute left-1 inline-flex items-center">
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</SelectPrimitive.Item>
|
||||
</RS.ItemIndicator>
|
||||
</RS.Item>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PropsWithChildren, useId } from 'react';
|
||||
import { PropsWithChildren, ReactNode, useId } from 'react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
|
||||
export interface UseFormFieldProps extends PropsWithChildren {
|
||||
@@ -19,9 +19,10 @@ export const useFormField = <P extends UseFormFieldProps>(props: P) => {
|
||||
};
|
||||
};
|
||||
|
||||
interface FormFieldProps extends UseFormFieldProps {
|
||||
interface FormFieldProps extends Omit<UseFormFieldProps, 'label'> {
|
||||
id: string;
|
||||
error?: string;
|
||||
label?: string | ReactNode;
|
||||
}
|
||||
|
||||
export const FormField = (props: FormFieldProps) => {
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { forwardRef } from 'react';
|
||||
import { zxcvbn, zxcvbnOptions } from '@zxcvbn-ts/core';
|
||||
import zxcvbnCommonPackage from '@zxcvbn-ts/language-common';
|
||||
import zxcvbnEnPackage from '@zxcvbn-ts/language-en';
|
||||
import clsx from 'clsx';
|
||||
import { forwardRef, useEffect, useState } from 'react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import { useDebounce, useDebouncedCallback } from 'use-debounce';
|
||||
import * as Root from '../Input';
|
||||
import { FormField, UseFormFieldProps, useFormField } from './FormField';
|
||||
|
||||
@@ -17,16 +22,96 @@ export const Input = forwardRef<HTMLInputElement, InputProps>((props, ref) => {
|
||||
);
|
||||
});
|
||||
|
||||
export const PasswordInput = forwardRef<HTMLInputElement, InputProps>((props, ref) => {
|
||||
const { formFieldProps, childProps } = useFormField(props);
|
||||
export interface PasswordInputProps extends UseFormFieldProps, Root.InputProps {
|
||||
name: string;
|
||||
showStrength?: boolean;
|
||||
}
|
||||
|
||||
const PasswordStrengthMeter = (props: { password: string }) => {
|
||||
const [strength, setStrength] = useState<{ label: string; score: number }>();
|
||||
const updateStrength = useDebouncedCallback(
|
||||
() => setStrength(props.password ? getPasswordStrength(props.password) : undefined),
|
||||
100
|
||||
);
|
||||
|
||||
// TODO: Remove duplicate in @sd/client
|
||||
function getPasswordStrength(password: string): { label: string; score: number } {
|
||||
const ratings = ['Poor', 'Weak', 'Good', 'Strong', 'Perfect'];
|
||||
|
||||
zxcvbnOptions.setOptions({
|
||||
dictionary: {
|
||||
...zxcvbnCommonPackage.dictionary,
|
||||
...zxcvbnEnPackage.dictionary
|
||||
},
|
||||
graphs: zxcvbnCommonPackage.adjacencyGraphs,
|
||||
translations: zxcvbnEnPackage.translations
|
||||
});
|
||||
|
||||
const result = zxcvbn(password);
|
||||
return { label: ratings[result.score]!, score: result.score };
|
||||
}
|
||||
|
||||
useEffect(() => updateStrength(), [props.password]);
|
||||
|
||||
return (
|
||||
<FormField {...formFieldProps}>
|
||||
<Root.PasswordInput
|
||||
{...childProps}
|
||||
ref={ref}
|
||||
error={formFieldProps.error !== undefined}
|
||||
/>
|
||||
</FormField>
|
||||
<div className="flex grow items-center justify-end">
|
||||
{strength && (
|
||||
<span
|
||||
className={clsx(
|
||||
'mr-2 text-xs',
|
||||
strength.score === 0 && 'text-red-500',
|
||||
strength.score === 1 && 'text-red-500',
|
||||
strength.score === 2 && 'text-amber-400',
|
||||
strength.score === 3 && 'text-lime-500',
|
||||
strength.score === 4 && 'text-accent'
|
||||
)}
|
||||
>
|
||||
{strength.label}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className={clsx('h-[6px] w-1/4 rounded-full bg-app-selected')}>
|
||||
{strength && (
|
||||
<div
|
||||
style={{
|
||||
width: `${strength.score !== 0 ? strength.score * 25 : 12.5}%`
|
||||
}}
|
||||
className={clsx(
|
||||
'h-full rounded-full',
|
||||
strength.score === 0 && 'bg-red-500',
|
||||
strength.score === 1 && 'bg-red-500',
|
||||
strength.score === 2 && 'bg-amber-400',
|
||||
strength.score === 3 && 'bg-lime-500',
|
||||
strength.score === 4 && 'bg-accent'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export const PasswordInput = forwardRef<HTMLInputElement, PasswordInputProps>(
|
||||
({ showStrength, ...props }, ref) => {
|
||||
const { formFieldProps, childProps } = useFormField(props);
|
||||
const { watch } = useFormContext();
|
||||
|
||||
return (
|
||||
<FormField
|
||||
{...formFieldProps}
|
||||
label={
|
||||
<>
|
||||
{formFieldProps.label}
|
||||
{showStrength && <PasswordStrengthMeter password={watch(props.name)} />}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Root.PasswordInput
|
||||
{...childProps}
|
||||
ref={ref}
|
||||
error={formFieldProps.error !== undefined}
|
||||
/>
|
||||
</FormField>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
24
packages/ui/src/forms/Select.tsx
Normal file
24
packages/ui/src/forms/Select.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { FieldValues, UseControllerProps, useController } from 'react-hook-form';
|
||||
import * as Root from '../Select';
|
||||
import { FormField, UseFormFieldProps, useFormField } from './FormField';
|
||||
|
||||
export interface SelectProps<T extends FieldValues>
|
||||
extends Omit<UseFormFieldProps, 'name'>,
|
||||
Omit<Root.SelectProps, 'value' | 'onChange'>,
|
||||
UseControllerProps<T> {}
|
||||
|
||||
export const Select = <T extends FieldValues>(props: SelectProps<T>) => {
|
||||
const { formFieldProps, childProps } = useFormField(props);
|
||||
const { field } = useController({ name: props.name });
|
||||
|
||||
return (
|
||||
<FormField {...formFieldProps}>
|
||||
<Root.Select
|
||||
{...childProps}
|
||||
className="w-full"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
</FormField>
|
||||
);
|
||||
};
|
||||
@@ -3,4 +3,5 @@ export * from './FormField';
|
||||
export * from './CheckBox';
|
||||
export * from './Input';
|
||||
export * from './Switch';
|
||||
export * from './Select';
|
||||
export * as RadioGroup from './RadioGroup';
|
||||
|
||||
BIN
pnpm-lock.yaml
generated
BIN
pnpm-lock.yaml
generated
Binary file not shown.
Reference in New Issue
Block a user