align input shortcuts

This commit is contained in:
Salihu
2026-09-01 18:43:31 +01:00
parent cd9c166927
commit 880ed2870d
8 changed files with 111 additions and 25 deletions
@@ -18,6 +18,7 @@ export type FieldProps = {
rowId: string; rowId: string;
readOnly: boolean; readOnly: boolean;
onChange: (value: unknown) => void; onChange: (value: unknown) => void;
onEditingChange?: (editing: boolean) => void;
}; };
type FieldShellProps = { type FieldShellProps = {
@@ -99,9 +100,10 @@ type DetailFieldProps = {
row: IBaseRow; row: IBaseRow;
readOnly: boolean; readOnly: boolean;
onUpdate: (propertyId: string, value: unknown) => void; onUpdate: (propertyId: string, value: unknown) => void;
onEditingChange: (editing: boolean) => void;
}; };
export function DetailField({ property, row, readOnly, onUpdate }: DetailFieldProps) { export function DetailField({ property, row, readOnly, onUpdate, onEditingChange }: DetailFieldProps) {
const descriptor = getDescriptor(property.type); const descriptor = getDescriptor(property.type);
const value = descriptor?.systemAccessor const value = descriptor?.systemAccessor
? descriptor.systemAccessor(row) ? descriptor.systemAccessor(row)
@@ -112,6 +114,7 @@ export function DetailField({ property, row, readOnly, onUpdate }: DetailFieldPr
rowId: row.id, rowId: row.id,
readOnly, readOnly,
onChange: (next: unknown) => onUpdate(property.id, next), onChange: (next: unknown) => onUpdate(property.id, next),
onEditingChange
}; };
switch (property.type) { switch (property.type) {
@@ -9,7 +9,13 @@ const normalize = (s: string) => {
return trimmed.length ? trimmed : null; return trimmed.length ? trimmed : null;
}; };
export function FieldLongText({ property, value, readOnly, onChange }: FieldProps) { export function FieldLongText({
property,
value,
readOnly,
onChange,
onEditingChange,
}: FieldProps) {
const text = toText(value); const text = toText(value);
const [draft, setDraft] = useState(text); const [draft, setDraft] = useState(text);
const [focused, setFocused] = useState(false); const [focused, setFocused] = useState(false);
@@ -23,6 +29,7 @@ export function FieldLongText({ property, value, readOnly, onChange }: FieldProp
const commit = () => { const commit = () => {
setFocused(false); setFocused(false);
onEditingChange?.(false);
if (cancelRef.current) { if (cancelRef.current) {
cancelRef.current = false; cancelRef.current = false;
setDraft(text); setDraft(text);
@@ -50,7 +57,10 @@ export function FieldLongText({ property, value, readOnly, onChange }: FieldProp
className={classes.fieldTextarea} className={classes.fieldTextarea}
classNames={{ input: classes.fieldTextareaInput }} classNames={{ input: classes.fieldTextareaInput }}
value={draft} value={draft}
onFocus={() => setFocused(true)} onFocus={() => {
setFocused(true);
onEditingChange?.(true);
}}
onChange={(e) => setDraft(e.currentTarget.value)} onChange={(e) => setDraft(e.currentTarget.value)}
onBlur={commit} onBlur={commit}
onKeyDown={(e) => { onKeyDown={(e) => {
@@ -11,7 +11,13 @@ import classes from "@/ee/base/styles/row-detail-modal.module.css";
const toDraft = (value: unknown) => const toDraft = (value: unknown) =>
typeof value === "number" ? String(value) : ""; typeof value === "number" ? String(value) : "";
export function FieldNumber({ property, value, readOnly, onChange }: FieldProps) { export function FieldNumber({
property,
value,
readOnly,
onChange,
onEditingChange,
}: FieldProps) {
const typeOptions = property.typeOptions as NumberTypeOptions | undefined; const typeOptions = property.typeOptions as NumberTypeOptions | undefined;
const numValue = typeof value === "number" ? value : null; const numValue = typeof value === "number" ? value : null;
const [draft, setDraft] = useState(toDraft(value)); const [draft, setDraft] = useState(toDraft(value));
@@ -36,6 +42,7 @@ export function FieldNumber({ property, value, readOnly, onChange }: FieldProps)
const commit = () => { const commit = () => {
setFocused(false); setFocused(false);
onEditingChange?.(false);
if (cancelRef.current) { if (cancelRef.current) {
cancelRef.current = false; cancelRef.current = false;
setDraft(toDraft(value)); setDraft(toDraft(value));
@@ -54,6 +61,7 @@ export function FieldNumber({ property, value, readOnly, onChange }: FieldProps)
onFocus={() => { onFocus={() => {
setDraft(toDraft(value)); setDraft(toDraft(value));
setFocused(true); setFocused(true);
onEditingChange?.(true);
}} }}
onChange={(e) => { onChange={(e) => {
const v = e.target.value; const v = e.target.value;
@@ -5,7 +5,13 @@ import classes from "@/ee/base/styles/row-detail-modal.module.css";
const toText = (value: unknown) => (typeof value === "string" ? value : ""); const toText = (value: unknown) => (typeof value === "string" ? value : "");
export function FieldText({ property, value, readOnly, onChange }: FieldProps) { export function FieldText({
property,
value,
readOnly,
onChange,
onEditingChange,
}: FieldProps) {
const text = toText(value); const text = toText(value);
const [draft, setDraft] = useState(text); const [draft, setDraft] = useState(text);
const [focused, setFocused] = useState(false); const [focused, setFocused] = useState(false);
@@ -20,6 +26,7 @@ export function FieldText({ property, value, readOnly, onChange }: FieldProps) {
const commit = () => { const commit = () => {
setFocused(false); setFocused(false);
onEditingChange?.(false);
if (cancelRef.current) { if (cancelRef.current) {
cancelRef.current = false; cancelRef.current = false;
setDraft(text); setDraft(text);
@@ -54,7 +61,10 @@ export function FieldText({ property, value, readOnly, onChange }: FieldProps) {
className={classes.fieldInput} className={classes.fieldInput}
value={draft} value={draft}
maxLength={1000} maxLength={1000}
onFocus={() => setFocused(true)} onFocus={() => {
setFocused(true);
onEditingChange?.(true);
}}
onChange={(e) => setDraft(e.currentTarget.value)} onChange={(e) => setDraft(e.currentTarget.value)}
onBlur={commit} onBlur={commit}
onKeyDown={(e) => { onKeyDown={(e) => {
@@ -17,6 +17,7 @@ type PropertyRowProps = {
onMenuOpenChange: (opened: boolean) => void; onMenuOpenChange: (opened: boolean) => void;
onMenuDirtyChange: (dirty: boolean) => void; onMenuDirtyChange: (dirty: boolean) => void;
onUpdate: (propertyId: string, value: unknown) => void; onUpdate: (propertyId: string, value: unknown) => void;
onEditingChange?: (editing: boolean) => void;
autoFocusValue?: boolean; autoFocusValue?: boolean;
onAutoFocused?: () => void; onAutoFocused?: () => void;
}; };
@@ -29,6 +30,7 @@ export function PropertyRow({
onMenuOpenChange, onMenuOpenChange,
onMenuDirtyChange, onMenuDirtyChange,
onUpdate, onUpdate,
onEditingChange,
autoFocusValue, autoFocusValue,
onAutoFocused, onAutoFocused,
}: PropertyRowProps) { }: PropertyRowProps) {
@@ -112,6 +114,7 @@ export function PropertyRow({
row={row} row={row}
readOnly={!canEdit} readOnly={!canEdit}
onUpdate={onUpdate} onUpdate={onUpdate}
onEditingChange={onEditingChange}
/> />
</div> </div>
); );
@@ -75,6 +75,7 @@ export function RowDetailModal({
const isSaving = updateRowMutation.isPending; const isSaving = updateRowMutation.isPending;
const opened = !!openRowId; const opened = !!openRowId;
const [editingField, setEditingField] = useState(false);
// One field menu open at a time, mirroring the grid header's semantics. // One field menu open at a time, mirroring the grid header's semantics.
// The shared closeRequest atom asks an open dirty PropertyMenuContent to // The shared closeRequest atom asks an open dirty PropertyMenuContent to
@@ -90,6 +91,7 @@ export function RowDetailModal({
useEffect(() => { useEffect(() => {
setOpenMenuId(null); setOpenMenuId(null);
menuDirtyRef.current = false; menuDirtyRef.current = false;
setEditingField(false);
}, [openRowId]); }, [openRowId]);
const handleMenuDirtyChange = useCallback((dirty: boolean) => { const handleMenuDirtyChange = useCallback((dirty: boolean) => {
@@ -293,7 +295,7 @@ export function RowDetailModal({
row={row} row={row}
primaryProperty={primaryProperty} primaryProperty={primaryProperty}
canEdit={canEdit} canEdit={canEdit}
onClose={onClose} onEditingChange={setEditingField}
onCommit={(value) => { onCommit={(value) => {
if (!primaryProperty) return; if (!primaryProperty) return;
updateRowMutation.mutate({ updateRowMutation.mutate({
@@ -317,6 +319,7 @@ export function RowDetailModal({
autoFocusValue={property.id === newPropertyId} autoFocusValue={property.id === newPropertyId}
onAutoFocused={clearNewProperty} onAutoFocused={clearNewProperty}
menuOpened={openMenuId === property.id} menuOpened={openMenuId === property.id}
onEditingChange={setEditingField}
onMenuOpenChange={(nextOpened) => onMenuOpenChange={(nextOpened) =>
handleMenuOpenChange(property.id, nextOpened) handleMenuOpenChange(property.id, nextOpened)
} }
@@ -367,16 +370,38 @@ export function RowDetailModal({
) : null} ) : null}
</div> </div>
<div className={classes.kbdHint}> <div className={classes.kbdHint}>
{rowIndex >= 0 && rows.length > 1 && ( {editingField ? (
<> <>
<kbd className={classes.kbd}></kbd> <span className={classes.kbdGroup}>
<kbd className={classes.kbd}></kbd> <kbd className={classes.kbd}>Ctrl/Cmd</kbd>
<span>{t("to navigate")}</span> <span>+</span>
<kbd className={classes.kbd}>Enter</kbd>
<span>{t("to save")}</span>
</span>
<span className={classes.kbdSeparator} /> <span className={classes.kbdSeparator} />
<span className={classes.kbdGroup}>
<kbd className={classes.kbd}>Esc</kbd>
<span>{t("to reset")}</span>
</span>
</>
) : (
<>
{rowIndex >= 0 && rows.length > 1 && (
<>
<kbd className={classes.kbd}></kbd>
<kbd className={classes.kbd}></kbd>
<span>{t("to navigate")}</span>
<span className={classes.kbdSeparator} />
</>
)}
<>
<kbd className={classes.kbd}>Esc</kbd>
<span>{t("to close")}</span>
</>
</> </>
)} )}
<kbd className={classes.kbd}>Esc</kbd>
<span>{t("to close")}</span>
</div> </div>
</footer> </footer>
</> </>
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { IBaseProperty, IBaseRow } from "@/ee/base/types/base.types"; import { IBaseProperty, IBaseRow } from "@/ee/base/types/base.types";
import { timeAgo } from "@/lib/time.ts"; import { timeAgo } from "@/lib/time.ts";
@@ -9,7 +9,7 @@ type RowDetailTitleProps = {
primaryProperty: IBaseProperty | undefined; primaryProperty: IBaseProperty | undefined;
canEdit: boolean; canEdit: boolean;
onCommit: (value: string) => void; onCommit: (value: string) => void;
onClose: () => void; onEditingChange?: (editing: boolean) => void;
}; };
export function RowDetailTitle({ export function RowDetailTitle({
@@ -17,13 +17,24 @@ export function RowDetailTitle({
primaryProperty, primaryProperty,
canEdit, canEdit,
onCommit, onCommit,
onClose, onEditingChange,
}: RowDetailTitleProps) { }: RowDetailTitleProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const initial = primaryProperty const initial = primaryProperty
? (((row.cells ?? {})[primaryProperty.id] as string) ?? "") ? (((row.cells ?? {})[primaryProperty.id] as string) ?? "")
: ""; : "";
const [value, setValue] = useState(initial); const [value, setValue] = useState(initial);
const cancelRef = useRef(false);
const commit = () => {
onEditingChange?.(false);
if (cancelRef.current) {
cancelRef.current = false;
setValue(initial)
return;
}
if (value !== initial) onCommit(value);
};
// Re-sync when the row changes underneath us (navigation or remote edit). // Re-sync when the row changes underneath us (navigation or remote edit).
useEffect(() => { useEffect(() => {
@@ -43,18 +54,18 @@ export function RowDetailTitle({
aria-label={primaryProperty?.name ?? t("Untitled")} aria-label={primaryProperty?.name ?? t("Untitled")}
value={value} value={value}
maxLength={1000} maxLength={1000}
onChange={(e) => setValue(e.currentTarget.value)} onFocus={() => {
onBlur={() => { onEditingChange?.(true);
if (value !== initial) onCommit(value);
}} }}
onChange={(e) => setValue(e.currentTarget.value)}
onBlur={commit}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter") { if (e.key === "Escape") {
cancelRef.current = true;
e.currentTarget.blur();
} else if (e.key === "Enter") {
e.preventDefault(); e.preventDefault();
(e.currentTarget as HTMLInputElement).blur(); e.currentTarget.blur();
} else if (e.key === "Escape") {
e.preventDefault();
(e.currentTarget as HTMLInputElement).blur();
onClose();
} }
}} }}
/> />
@@ -416,9 +416,25 @@
} }
.kbdHint { .kbdHint {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
flex-wrap: wrap;
width: 100%;
}
.kbdGroup {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
white-space: nowrap;
height: fit-content;
}
.kbdPlus {
color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3));
font-size: 11px;
} }
.kbdSeparator { .kbdSeparator {