setHasFocus(true)}
+ className="flex flex-col w-full group"
+ onFocus={() => setShouldShowMenu(true)}
onBlur={e => {
if (!e.currentTarget.contains(e.relatedTarget)) {
- setHasFocus(false);
- setSelectedOptionIndex(undefined);
+ hideMenu();
}
}}
>
-
+
+ {selectedOptions.length === 0
+ ? 'No tags selected'
+ : selectedOptions.join(', ') +
+ ` tag${selectedOptions.length !== 1 ? 's' : ''} selected`}
+
+
{selectedOptions
.filter(Boolean)
.map(option =>
removeOption(option)}
@@ -206,43 +253,64 @@ export default function TagInput({
autoComplete="off"
autoCapitalize="off"
readOnly={readOnly}
+ onFocus={() => setSelectedOptionIndex(undefined)}
+ aria-autocomplete="list"
+ aria-expanded={shouldShowMenu}
+ aria-haspopup="true"
+ aria-controls={shouldShowMenu ? ARIA_ID_TAG_OPTIONS : undefined}
+ role="combobox"
/>
-
-
0) && 'hidden',
- 'control absolute top-0 mt-3 w-full z-10 !px-1.5 !py-1.5',
- 'max-h-[8rem] overflow-y-auto',
- 'flex flex-col gap-y-1',
- 'text-xl shadow-lg dark:shadow-xl',
- )}
- >
- {optionsFiltered.map((option, index) =>
-
{
- addOption(option);
- setInputText('');
- }}
- >
- {option}
-
)}
-
-
+ {shouldShowMenu && optionsFiltered.length > 0 &&
+
+
+ {optionsFiltered.map(({ value, annotation }, index) =>
+
{
+ addOption(value);
+ setInputText('');
+ }}
+ onFocus={() => setSelectedOptionIndex(index)}
+ >
+
+ {value}
+
+ {annotation &&
+
+ {annotation}
+ }
+
)}
+
+
}
);
}
diff --git a/src/photo/PhotoEditPageClient.tsx b/src/photo/PhotoEditPageClient.tsx
index 0bf00b99..0969d8c0 100644
--- a/src/photo/PhotoEditPageClient.tsx
+++ b/src/photo/PhotoEditPageClient.tsx
@@ -10,13 +10,14 @@ import { useFormState } from 'react-dom';
import { areSimpleObjectsEqual } from '@/utility/object';
import IconGrSync from '@/site/IconGrSync';
import { getExifDataAction } from './actions';
+import { Tags } from '@/tag';
export default function PhotoEditPageClient({
photo,
uniqueTags,
}: {
photo: Photo
- uniqueTags?: string[]
+ uniqueTags?: Tags
}) {
const seedExifData = { url: photo.url };
diff --git a/src/photo/form/PhotoForm.tsx b/src/photo/form/PhotoForm.tsx
index 61b78a2d..5d083b72 100644
--- a/src/photo/form/PhotoForm.tsx
+++ b/src/photo/form/PhotoForm.tsx
@@ -5,7 +5,7 @@ import {
FORM_METADATA_ENTRIES,
PhotoFormData,
convertFormKeysToLabels,
- getInitialErrors,
+ getFormErrors,
isFormValid,
} from '.';
import FieldSetWithStatus from '@/components/FieldSetWithStatus';
@@ -23,7 +23,7 @@ import { toastSuccess, toastWarning } from '@/toast';
import { getDimensionsFromSize } from '@/utility/size';
import ImageBlurFallback from '@/components/ImageBlurFallback';
import { BLUR_ENABLED } from '@/site/config';
-import { sortTagsWithoutFavs } from '@/tag';
+import { Tags, sortTagsObjectWithoutFavs } from '@/tag';
const THUMBNAIL_SIZE = 300;
@@ -37,13 +37,13 @@ export default function PhotoForm({
initialPhotoForm: Partial
updatedExifData?: Partial
type?: 'create' | 'edit'
- uniqueTags?: string[]
+ uniqueTags?: Tags
debugBlur?: boolean
}) {
const [formData, setFormData] =
useState>(initialPhotoForm);
const [formErrors, setFormErrors] =
- useState(getInitialErrors(initialPhotoForm));
+ useState(getFormErrors(initialPhotoForm));
// Update form when EXIF data
// is refreshed by parent
@@ -146,51 +146,57 @@ export default function PhotoForm({
onSubmit={() => blur()}
className="space-y-6"
>
- {FORM_METADATA_ENTRIES.map(([key, {
- label,
- note,
- required,
- options,
- optionsDefaultLabel,
- readOnly,
- validate,
- capitalize,
- hideIfEmpty,
- hideBasedOnCamera,
- loadingMessage,
- type,
- }]) =>
- (
- (!hideIfEmpty || formData[key]) &&
- !hideBasedOnCamera?.(formData.make)
- ) &&
- {
- setFormData({ ...formData, [key]: value });
- if (validate) {
- setFormErrors({ ...formErrors, [key]: validate(value) });
- }
- }}
- selectOptions={options}
- selectOptionsDefaultLabel={optionsDefaultLabel}
- commaSeparatedOptions={key === 'tags'
- ? sortTagsWithoutFavs(uniqueTags ?? [])
- : undefined}
- required={required}
- readOnly={readOnly}
- capitalize={capitalize}
- placeholder={loadingMessage && !formData[key]
- ? loadingMessage
- : undefined}
- loading={loadingMessage && !formData[key] ? true : false}
- type={type}
- />)}
+ {FORM_METADATA_ENTRIES(
+ sortTagsObjectWithoutFavs(uniqueTags ?? [])
+ .map(({ tag, count }) => ({
+ value: tag,
+ annotation: `× ${count}`,
+ }))
+ )
+ .map(([key, {
+ label,
+ note,
+ required,
+ selectOptions,
+ selectOptionsDefaultLabel,
+ tagOptions,
+ readOnly,
+ validate,
+ capitalize,
+ hideIfEmpty,
+ hideBasedOnCamera,
+ loadingMessage,
+ type,
+ }]) =>
+ (
+ (!hideIfEmpty || formData[key]) &&
+ !hideBasedOnCamera?.(formData.make)
+ ) &&
+ {
+ setFormData({ ...formData, [key]: value });
+ if (validate) {
+ setFormErrors({ ...formErrors, [key]: validate(value) });
+ }
+ }}
+ selectOptions={selectOptions}
+ selectOptionsDefaultLabel={selectOptionsDefaultLabel}
+ tagOptions={tagOptions}
+ required={required}
+ readOnly={readOnly}
+ capitalize={capitalize}
+ placeholder={loadingMessage && !formData[key]
+ ? loadingMessage
+ : undefined}
+ loading={loadingMessage && !formData[key] ? true : false}
+ type={type}
+ />)}
boolean
loadingMessage?: string
type?: FieldSetType
- options?: { value: string, label: string }[]
- optionsDefaultLabel?: string
+ selectOptions?: { value: string, label: string }[]
+ selectOptionsDefaultLabel?: string
+ tagOptions?: AnnotatedTag[]
};
-const FORM_METADATA: Record
= {
+const FORM_METADATA = (
+ tagOptions?: AnnotatedTag[]
+): Record => ({
title: { label: 'title', capitalize: true },
tags: {
label: 'tags',
+ tagOptions,
validate: tags => doesTagsStringIncludeFavs(tags)
? `'${TAG_FAVS}' is a reserved tag`
: undefined,
@@ -66,8 +72,8 @@ const FORM_METADATA: Record = {
model: { label: 'camera model' },
filmSimulation: {
label: 'fujifilm simulation',
- options: FILM_SIMULATION_FORM_INPUT_OPTIONS,
- optionsDefaultLabel: 'Unknown',
+ selectOptions: FILM_SIMULATION_FORM_INPUT_OPTIONS,
+ selectOptionsDefaultLabel: 'Unknown',
hideBasedOnCamera: make => make !== MAKE_FUJIFILM,
},
focalLength: { label: 'focal length' },
@@ -84,26 +90,28 @@ const FORM_METADATA: Record = {
priorityOrder: { label: 'priority order' },
favorite: { label: 'favorite', type: 'checkbox', virtual: true },
hidden: { label: 'hidden', type: 'checkbox' },
-};
+});
-export const FORM_METADATA_ENTRIES =
- (Object.entries(FORM_METADATA) as [keyof PhotoFormData, FormMeta][])
+export const FORM_METADATA_ENTRIES = (
+ ...args: Parameters
+) =>
+ (Object.entries(FORM_METADATA(...args)) as [keyof PhotoFormData, FormMeta][])
.filter(([_, meta]) => !meta.hide);
export const convertFormKeysToLabels = (keys: (keyof PhotoFormData)[]) =>
- keys.map(key => FORM_METADATA[key].label.toUpperCase());
+ keys.map(key => FORM_METADATA()[key].label.toUpperCase());
-export const getInitialErrors = (
+export const getFormErrors = (
formData: Partial
): Partial> =>
Object.keys(formData).reduce((acc, key) => ({
...acc,
- [key]: FORM_METADATA_ENTRIES.find(([k]) => k === key)?.[1]
+ [key]: FORM_METADATA_ENTRIES().find(([k]) => k === key)?.[1]
.validate?.(formData[key as keyof PhotoFormData]),
}), {});
export const isFormValid = (formData: Partial) =>
- FORM_METADATA_ENTRIES.every(
+ FORM_METADATA_ENTRIES().every(
([key, { required, validate }]) =>
(!required || Boolean(formData[key])) &&
(validate?.(formData[key]) === undefined)
@@ -191,7 +199,7 @@ export const convertFormDataToPhotoDbInsert = (
if (
key.startsWith('$ACTION_ID_') ||
(photoForm as any)[key] === '' ||
- FORM_METADATA[key as keyof PhotoFormData]?.virtual
+ FORM_METADATA()[key as keyof PhotoFormData]?.virtual
) {
delete (photoForm as any)[key];
}
diff --git a/src/tag/index.ts b/src/tag/index.ts
index 03cf7699..58384d0a 100644
--- a/src/tag/index.ts
+++ b/src/tag/index.ts
@@ -46,6 +46,9 @@ export const sortTagsObject = (
export const sortTagsWithoutFavs = (tags: string[]) =>
sortTags(tags, TAG_FAVS);
+export const sortTagsObjectWithoutFavs = (tags: Tags) =>
+ sortTagsObject(tags, TAG_FAVS);
+
export const descriptionForTaggedPhotos = (
photos: Photo[],
dateBased?: boolean,