mirror of
https://github.com/docmost/docmost.git
synced 2026-08-29 01:37:05 +08:00
feat(ee): bases (#2295)
* feat(ee): bases Table and kanban UI, formula engine package, and the base-embed editor extension. * - default status - type fix - error helper * fix: base trash list handling * feat: base nodeview menu * feat: translation * fix number precision * feat(base): add focused-cell atom and cell coordinate types * feat(base): add cell focus-ring style * feat(base): add pure next-cell navigation helper * feat(base): keyboard navigation controller and grid wiring * update offerings * feat(base): cell focus ring, click-to-focus, and gridcell ARIA * feat(base): row ARIA index and selected state * feat(base): seed editor value on type-to-edit for free-text cells * feat(base): make column headers keyboard-focusable as tab stops * fix(base): remove focus outline on grid container * fix(base): show cell focus ring only while the grid is focused * feat(base): keyboard-navigate the row-number column for selection * fix(base): sync header/body horizontal scroll on header focus; expand row via Space, drop expander from tab order * fix(base): tab from long-text editor moves to next cell instead of leaving the table * fix(base): close view popovers on Escape regardless of focus; drop redundant property switch tab stop * fix(base): show cell focus ring only while the grid body itself is focused * fix(base): render view-tab rename as an inline pill so the tab band height stays put * fix(base): refer to the feature as 'base' rather than 'database' * fix: change permissions object shape * license file * fix tsconfig * fix base cache * fix: preserve sidebar title/icon on partial page updates * fix: skip duplicate row fetch when opening new kanban card * fix refetch * fix focus * fix spacing * fix(base): select grid cell on mousedown to avoid stale focus ring flash The focus ring is gated on the grid having DOM focus (.bodyGrid:focus .cellFocused), but the focusedCell atom is never cleared when the grid blurs. Clicking outside hides the ring via the :focus gate while the atom still points at the old cell. Selection was committed on click (mouseup), while the grid receives focus on mousedown. Clicking a new cell re-focused the grid before the atom updated, briefly painting the ring on the previously selected cell. Commit selection on mousedown so the atom updates in the same event that grants focus, before the browser paints. * fix: activate New row button via keyboard (Enter/Space) The New row control is a role=button div with no keydown handler, so Enter/Space never triggered it. It also lives inside the grid element, whose native keydown listener caught the Enter and ran cell navigation against the previously focused cell. Add Enter/Space activation to the button, and make the grid keyboard handler ignore keydowns that originate from a focusable child rather than the grid element itself, so in-grid controls handle their own keys. * fix(base): keep add-property popover within viewport on mobile Opened from the row detail modal, the create-property popover anchors to the bottom Add property button and flips upward on small screens, clipping its top (name field, formula editor) off-screen with no way to scroll to it. Bound the dropdown to the available height with the floating-ui size middleware and give it an internal scroll container. Disable react-remove-scroll isolation on the modal so the body-portaled popover can scroll on touch while the modal scroll lock stays active. * fix(base): enable grid cell editing on touch devices Cells could only enter edit mode via double-click or a physical keyboard, so touch devices had no way to edit a cell. Treat a touch/pen tap as the edit gesture, distinguishing a tap from a scroll by movement and branching per pointer type so mouse double-click stays unchanged. Also reveal the row expand button on hover-less devices so the row detail view stays reachable. * feat(editor): add base and kanban inserts to the toolbar * feat(base): insert row below via Shift+Enter on the primary cell * fix(base): place caret at end instead of selecting all when editing cells * fix(base): prevent popover inputs from losing focus on mobile in row detail modal * fix grid cells on mobile * sync * fix: read-only export * feat(base): add prefixed nanoid id schemas and generators * feat(base): enforce strict property/choice id validation * feat(base): make property id varchar with per-base composite pk * feat(base): pass property id as text to cell extractors * feat(base): scope property lookups per base and generate property ids in repo * feat(base): generate status template choice ids as nanoid * feat(base): generate choice ids as nanoid on the client * chore(base): seed choice ids with nanoid * fix(base): mint kanban choice ids as nanoid * sync * sync * sync
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
import { forwardRef } from "react";
|
||||
import { Checkbox } from "@mantine/core";
|
||||
import { IconLock } from "@tabler/icons-react";
|
||||
import clsx from "clsx";
|
||||
import { IBaseProperty, IBaseRow } from "@/ee/base/types/base.types";
|
||||
import { getDescriptor } from "@/ee/base/property-types/property-type.registry";
|
||||
import { FieldText } from "./field-text";
|
||||
import { FieldLongText } from "./field-long-text";
|
||||
import { FieldNumber } from "./field-number";
|
||||
import { FieldDate } from "./field-date";
|
||||
import { FieldChoice } from "./field-choice";
|
||||
import { FieldCellAdapter } from "./field-cell-adapter";
|
||||
import classes from "@/ee/base/styles/row-detail-modal.module.css";
|
||||
|
||||
export type FieldProps = {
|
||||
property: IBaseProperty;
|
||||
value: unknown;
|
||||
rowId: string;
|
||||
readOnly: boolean;
|
||||
onChange: (value: unknown) => void;
|
||||
};
|
||||
|
||||
type FieldShellProps = {
|
||||
/** Visual + cursor treatment: text caret, pointer (opens a picker), or none. */
|
||||
cursor?: "text" | "pointer" | "default";
|
||||
/** Popover open — keeps the focus ring while focus is in the portal. */
|
||||
active?: boolean;
|
||||
locked?: boolean;
|
||||
alignTop?: boolean;
|
||||
children?: React.ReactNode;
|
||||
} & React.HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
// forwardRef is load-bearing: Popover.Target anchors its dropdown through a
|
||||
// ref injected into this element; without it the picker renders at (0,0).
|
||||
export const FieldShell = forwardRef<HTMLDivElement, FieldShellProps>(
|
||||
function FieldShell(
|
||||
{ cursor = "default", active, locked, alignTop, className, children, ...rest },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={clsx(
|
||||
classes.fieldShell,
|
||||
cursor === "text" && classes.fieldShellText,
|
||||
cursor === "pointer" && classes.fieldShellPointer,
|
||||
active && classes.fieldShellActive,
|
||||
locked && classes.fieldShellLocked,
|
||||
alignTop && classes.fieldShellTop,
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{locked && <IconLock size={13} className={classes.fieldLockIcon} />}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
function FieldCheckbox({ value, readOnly, onChange }: FieldProps) {
|
||||
const checked = value === true;
|
||||
return (
|
||||
<FieldShell>
|
||||
<Checkbox
|
||||
size="sm"
|
||||
checked={checked}
|
||||
disabled={readOnly}
|
||||
onChange={() => onChange(!checked)}
|
||||
/>
|
||||
</FieldShell>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldReadonlyCell({ property, value, rowId }: FieldProps) {
|
||||
const CellComponent = getDescriptor(property.type)?.cellComponent;
|
||||
return (
|
||||
<FieldShell locked>
|
||||
<div className={classes.fieldCellDisplay}>
|
||||
{CellComponent && (
|
||||
<CellComponent
|
||||
value={value}
|
||||
property={property}
|
||||
rowId={rowId}
|
||||
isEditing={false}
|
||||
readOnly
|
||||
onCommit={() => {}}
|
||||
onValueChange={() => {}}
|
||||
onCancel={() => {}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</FieldShell>
|
||||
);
|
||||
}
|
||||
|
||||
type DetailFieldProps = {
|
||||
property: IBaseProperty;
|
||||
row: IBaseRow;
|
||||
readOnly: boolean;
|
||||
onUpdate: (propertyId: string, value: unknown) => void;
|
||||
};
|
||||
|
||||
export function DetailField({ property, row, readOnly, onUpdate }: DetailFieldProps) {
|
||||
const descriptor = getDescriptor(property.type);
|
||||
const value = descriptor?.systemAccessor
|
||||
? descriptor.systemAccessor(row)
|
||||
: (row.cells ?? {})[property.id];
|
||||
const fieldProps: FieldProps = {
|
||||
property,
|
||||
value,
|
||||
rowId: row.id,
|
||||
readOnly,
|
||||
onChange: (next: unknown) => onUpdate(property.id, next),
|
||||
};
|
||||
|
||||
switch (property.type) {
|
||||
case "text":
|
||||
case "url":
|
||||
case "email":
|
||||
return <FieldText {...fieldProps} />;
|
||||
case "longText":
|
||||
return <FieldLongText {...fieldProps} />;
|
||||
case "number":
|
||||
return <FieldNumber {...fieldProps} />;
|
||||
case "checkbox":
|
||||
return <FieldCheckbox {...fieldProps} />;
|
||||
case "date":
|
||||
return <FieldDate {...fieldProps} />;
|
||||
case "select":
|
||||
case "status":
|
||||
case "multiSelect":
|
||||
return <FieldChoice {...fieldProps} />;
|
||||
case "person":
|
||||
case "file":
|
||||
case "page":
|
||||
return <FieldCellAdapter {...fieldProps} />;
|
||||
default:
|
||||
// createdAt, lastEditedAt, lastEditedBy, formula and future types.
|
||||
return <FieldReadonlyCell {...fieldProps} />;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { getDescriptor } from "@/ee/base/property-types/property-type.registry";
|
||||
import { FieldProps, FieldShell } from "./detail-field";
|
||||
import classes from "@/ee/base/styles/row-detail-modal.module.css";
|
||||
|
||||
/** Person, file and page editors are popover pickers owned by their cell
|
||||
* components; the shell supplies modal styling and click-anywhere
|
||||
* activation while the cell keeps its picker behavior. */
|
||||
export function FieldCellAdapter({
|
||||
property,
|
||||
value,
|
||||
rowId,
|
||||
readOnly,
|
||||
onChange,
|
||||
}: FieldProps) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
// Whether the picker was open when the current gesture's mousedown fired.
|
||||
const editingAtMouseDownRef = useRef(false);
|
||||
const CellComponent = getDescriptor(property.type)?.cellComponent;
|
||||
|
||||
// Files stay openable read-only (download-only popover), matching the grid.
|
||||
const canActivate = !readOnly || property.type === "file";
|
||||
|
||||
// Activate on click, not mousedown: opening on mousedown mounts the cell's
|
||||
// picker mid-dispatch, and its document-level outside-mousedown listener
|
||||
// then catches the same still-bubbling event and instantly closes it. By
|
||||
// click time the mousedown has fully finished. The ref keeps toggle-close
|
||||
// working: when the gesture started with the picker open, the picker's own
|
||||
// outside-close already handled it and we must not reopen.
|
||||
const handleMouseDown = useCallback(() => {
|
||||
editingAtMouseDownRef.current = editing;
|
||||
}, [editing]);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (!canActivate || editingAtMouseDownRef.current || editing) return;
|
||||
setEditing(true);
|
||||
}, [canActivate, editing]);
|
||||
|
||||
const handleCommit = useCallback(
|
||||
(next: unknown) => {
|
||||
setEditing(false);
|
||||
onChange(next);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
const handleCancel = useCallback(() => setEditing(false), []);
|
||||
|
||||
if (!CellComponent) return <FieldShell />;
|
||||
|
||||
return (
|
||||
<FieldShell
|
||||
cursor={canActivate ? "pointer" : "default"}
|
||||
active={editing}
|
||||
onMouseDown={handleMouseDown}
|
||||
onClick={handleClick}
|
||||
role={canActivate ? "button" : undefined}
|
||||
tabIndex={canActivate ? 0 : undefined}
|
||||
aria-label={property.name}
|
||||
onKeyDown={(e) => {
|
||||
if (canActivate && !editing && (e.key === "Enter" || e.key === " ")) {
|
||||
e.preventDefault();
|
||||
setEditing(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className={classes.fieldCellDisplay}>
|
||||
<CellComponent
|
||||
value={value}
|
||||
property={property}
|
||||
rowId={rowId}
|
||||
isEditing={editing}
|
||||
readOnly={readOnly}
|
||||
onCommit={handleCommit}
|
||||
onValueChange={onChange}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
</div>
|
||||
</FieldShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { Popover } from "@mantine/core";
|
||||
import { Choice, SelectTypeOptions } from "@/ee/base/types/base.types";
|
||||
import { choiceColor } from "@/ee/base/components/cells/choice-color";
|
||||
import { ChoicePicker } from "@/ee/base/components/cells/choice-picker";
|
||||
import { FieldProps, FieldShell } from "./detail-field";
|
||||
import classes from "@/ee/base/styles/row-detail-modal.module.css";
|
||||
import cellClasses from "@/ee/base/styles/cells.module.css";
|
||||
|
||||
export function FieldChoice({ property, value, readOnly, onChange }: FieldProps) {
|
||||
const [opened, setOpened] = useState(false);
|
||||
const multiple = property.type === "multiSelect";
|
||||
const choices =
|
||||
(property.typeOptions as SelectTypeOptions | undefined)?.choices ?? [];
|
||||
|
||||
const selectedIds = multiple
|
||||
? Array.isArray(value)
|
||||
? (value as string[])
|
||||
: []
|
||||
: typeof value === "string"
|
||||
? [value]
|
||||
: [];
|
||||
const selectedChoices = choices.filter((c) => selectedIds.includes(c.id));
|
||||
|
||||
const handleToggle = useCallback(
|
||||
(choice: Choice) => {
|
||||
if (multiple) {
|
||||
const next = selectedIds.includes(choice.id)
|
||||
? selectedIds.filter((id) => id !== choice.id)
|
||||
: [...selectedIds, choice.id];
|
||||
onChange(next.length > 0 ? next : null);
|
||||
} else {
|
||||
onChange(choice.id === selectedIds[0] ? null : choice.id);
|
||||
setOpened(false);
|
||||
}
|
||||
},
|
||||
[multiple, selectedIds, onChange],
|
||||
);
|
||||
|
||||
const chips = selectedChoices.map((choice) => (
|
||||
<span
|
||||
key={choice.id}
|
||||
className={cellClasses.badge}
|
||||
style={choiceColor(choice.color)}
|
||||
>
|
||||
{choice.name}
|
||||
</span>
|
||||
));
|
||||
|
||||
if (readOnly) {
|
||||
return (
|
||||
<FieldShell>
|
||||
<div className={classes.fieldChips}>{chips}</div>
|
||||
</FieldShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
opened={opened}
|
||||
onChange={setOpened}
|
||||
position="bottom-start"
|
||||
width="target"
|
||||
shadow="md"
|
||||
withinPortal
|
||||
trapFocus
|
||||
closeOnClickOutside
|
||||
closeOnEscape={false}
|
||||
hideDetached={false}
|
||||
>
|
||||
<Popover.Target>
|
||||
<FieldShell
|
||||
cursor="pointer"
|
||||
active={opened}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={property.name}
|
||||
onClick={() => setOpened((o) => !o)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setOpened((o) => !o);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className={classes.fieldChips}>{chips}</div>
|
||||
</FieldShell>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown p={4}>
|
||||
{opened && (
|
||||
<ChoicePicker
|
||||
property={property}
|
||||
selectedIds={selectedIds}
|
||||
multiple={multiple}
|
||||
grouped={property.type === "status"}
|
||||
allowCreate={property.type !== "status"}
|
||||
onToggle={handleToggle}
|
||||
onEscape={() => setOpened(false)}
|
||||
/>
|
||||
)}
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useState } from "react";
|
||||
import { Popover } from "@mantine/core";
|
||||
import { DatePicker } from "@mantine/dates";
|
||||
import { DateTypeOptions } from "@/ee/base/types/base.types";
|
||||
import { formatDateDisplay } from "@/ee/base/components/cells/cell-date";
|
||||
import { FieldProps, FieldShell } from "./detail-field";
|
||||
import classes from "@/ee/base/styles/row-detail-modal.module.css";
|
||||
|
||||
function toISODateString(dateStr: string | null): string | null {
|
||||
if (!dateStr) return null;
|
||||
const date = new Date(dateStr);
|
||||
if (isNaN(date.getTime())) return null;
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
export function FieldDate({ property, value, readOnly, onChange }: FieldProps) {
|
||||
const [opened, setOpened] = useState(false);
|
||||
const typeOptions = property.typeOptions as DateTypeOptions | undefined;
|
||||
const dateStr = typeof value === "string" ? value : null;
|
||||
const display = formatDateDisplay(dateStr, typeOptions);
|
||||
|
||||
if (readOnly) {
|
||||
return (
|
||||
<FieldShell>
|
||||
<span className={classes.fieldValueText}>{display}</span>
|
||||
</FieldShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
opened={opened}
|
||||
onChange={setOpened}
|
||||
position="bottom-start"
|
||||
width="auto"
|
||||
shadow="md"
|
||||
withinPortal
|
||||
trapFocus
|
||||
closeOnClickOutside
|
||||
closeOnEscape
|
||||
hideDetached={false}
|
||||
>
|
||||
<Popover.Target>
|
||||
<FieldShell
|
||||
cursor="pointer"
|
||||
active={opened}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={property.name}
|
||||
onClick={() => setOpened((o) => !o)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setOpened((o) => !o);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className={classes.fieldValueText}>{display}</span>
|
||||
</FieldShell>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown p="xs">
|
||||
<DatePicker
|
||||
value={toISODateString(dateStr)}
|
||||
onChange={(selected) => {
|
||||
onChange(selected ? new Date(selected).toISOString() : null);
|
||||
setOpened(false);
|
||||
}}
|
||||
size="sm"
|
||||
/>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Textarea } from "@mantine/core";
|
||||
import { FieldProps, FieldShell } from "./detail-field";
|
||||
import classes from "@/ee/base/styles/row-detail-modal.module.css";
|
||||
|
||||
const toText = (value: unknown) => (typeof value === "string" ? value : "");
|
||||
const normalize = (s: string) => {
|
||||
const trimmed = s.trim();
|
||||
return trimmed.length ? trimmed : null;
|
||||
};
|
||||
|
||||
export function FieldLongText({ property, value, readOnly, onChange }: FieldProps) {
|
||||
const text = toText(value);
|
||||
const [draft, setDraft] = useState(text);
|
||||
const [focused, setFocused] = useState(false);
|
||||
// Esc sets this; blur() then runs commit synchronously with the stale
|
||||
// draft, so the revert must be decided here, not via setDraft.
|
||||
const cancelRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!focused) setDraft(text);
|
||||
}, [text, focused]);
|
||||
|
||||
const commit = () => {
|
||||
setFocused(false);
|
||||
if (cancelRef.current) {
|
||||
cancelRef.current = false;
|
||||
setDraft(text);
|
||||
return;
|
||||
}
|
||||
if (normalize(draft) !== normalize(text)) onChange(normalize(draft));
|
||||
};
|
||||
|
||||
if (readOnly) {
|
||||
return (
|
||||
<FieldShell alignTop>
|
||||
<span className={classes.fieldValueTextMultiline}>{text}</span>
|
||||
</FieldShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FieldShell cursor="text" alignTop>
|
||||
<Textarea
|
||||
autosize
|
||||
minRows={3}
|
||||
maxRows={16}
|
||||
maxLength={25000}
|
||||
variant="unstyled"
|
||||
className={classes.fieldTextarea}
|
||||
classNames={{ input: classes.fieldTextareaInput }}
|
||||
value={draft}
|
||||
onFocus={() => setFocused(true)}
|
||||
onChange={(e) => setDraft(e.currentTarget.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
cancelRef.current = true;
|
||||
e.currentTarget.blur();
|
||||
} else if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
e.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
aria-label={property.name}
|
||||
/>
|
||||
</FieldShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { NumberTypeOptions } from "@/ee/base/types/base.types";
|
||||
import {
|
||||
formatNumber,
|
||||
parseNumberDraft,
|
||||
sanitizeNumberInput,
|
||||
} from "@/ee/base/components/cells/cell-number";
|
||||
import { FieldProps, FieldShell } from "./detail-field";
|
||||
import classes from "@/ee/base/styles/row-detail-modal.module.css";
|
||||
|
||||
const toDraft = (value: unknown) =>
|
||||
typeof value === "number" ? String(value) : "";
|
||||
|
||||
export function FieldNumber({ property, value, readOnly, onChange }: FieldProps) {
|
||||
const typeOptions = property.typeOptions as NumberTypeOptions | undefined;
|
||||
const numValue = typeof value === "number" ? value : null;
|
||||
const [draft, setDraft] = useState(toDraft(value));
|
||||
const [focused, setFocused] = useState(false);
|
||||
// Esc sets this; blur() then runs commit synchronously with the stale
|
||||
// draft, so the revert must be decided here, not via setDraft.
|
||||
const cancelRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!focused) setDraft(toDraft(value));
|
||||
}, [value, focused]);
|
||||
|
||||
const formatted = formatNumber(numValue, typeOptions);
|
||||
|
||||
if (readOnly) {
|
||||
return (
|
||||
<FieldShell>
|
||||
<span className={classes.fieldValueText}>{formatted}</span>
|
||||
</FieldShell>
|
||||
);
|
||||
}
|
||||
|
||||
const commit = () => {
|
||||
setFocused(false);
|
||||
if (cancelRef.current) {
|
||||
cancelRef.current = false;
|
||||
setDraft(toDraft(value));
|
||||
return;
|
||||
}
|
||||
if (parseNumberDraft(draft) !== numValue) onChange(parseNumberDraft(draft));
|
||||
};
|
||||
|
||||
return (
|
||||
<FieldShell cursor="text">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
className={classes.fieldInput}
|
||||
value={focused ? draft : formatted}
|
||||
onFocus={() => {
|
||||
setDraft(toDraft(value));
|
||||
setFocused(true);
|
||||
}}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
if (v === "" || v === "-" || /^-?\d*\.?\d*$/.test(v)) {
|
||||
setDraft(v);
|
||||
}
|
||||
}}
|
||||
onPaste={(e) => {
|
||||
e.preventDefault();
|
||||
const el = e.currentTarget;
|
||||
const start = el.selectionStart ?? draft.length;
|
||||
const end = el.selectionEnd ?? draft.length;
|
||||
setDraft(
|
||||
draft.slice(0, start) +
|
||||
sanitizeNumberInput(e.clipboardData.getData("text")) +
|
||||
draft.slice(end),
|
||||
);
|
||||
}}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
e.currentTarget.blur();
|
||||
} else if (e.key === "Escape") {
|
||||
cancelRef.current = true;
|
||||
e.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
aria-label={property.name}
|
||||
/>
|
||||
</FieldShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { IconExternalLink, IconMail } from "@tabler/icons-react";
|
||||
import { FieldProps, FieldShell } from "./detail-field";
|
||||
import classes from "@/ee/base/styles/row-detail-modal.module.css";
|
||||
|
||||
const toText = (value: unknown) => (typeof value === "string" ? value : "");
|
||||
|
||||
export function FieldText({ property, value, readOnly, onChange }: FieldProps) {
|
||||
const text = toText(value);
|
||||
const [draft, setDraft] = useState(text);
|
||||
const [focused, setFocused] = useState(false);
|
||||
// Esc sets this; blur() then runs commit synchronously with the stale
|
||||
// draft, so the revert must be decided here, not via setDraft.
|
||||
const cancelRef = useRef(false);
|
||||
|
||||
// Track remote/navigation updates while not typing.
|
||||
useEffect(() => {
|
||||
if (!focused) setDraft(text);
|
||||
}, [text, focused]);
|
||||
|
||||
const commit = () => {
|
||||
setFocused(false);
|
||||
if (cancelRef.current) {
|
||||
cancelRef.current = false;
|
||||
setDraft(text);
|
||||
return;
|
||||
}
|
||||
if (draft !== text) onChange(draft);
|
||||
};
|
||||
|
||||
if (readOnly) {
|
||||
return (
|
||||
<FieldShell>
|
||||
<span className={classes.fieldValueText}>{text}</span>
|
||||
</FieldShell>
|
||||
);
|
||||
}
|
||||
|
||||
const linkHref =
|
||||
!focused && text
|
||||
? property.type === "email"
|
||||
? text.includes("@")
|
||||
? `mailto:${text}`
|
||||
: null
|
||||
: property.type === "url" && /^https?:\/\//i.test(text)
|
||||
? text
|
||||
: null
|
||||
: null;
|
||||
|
||||
return (
|
||||
<FieldShell cursor="text">
|
||||
<input
|
||||
type="text"
|
||||
className={classes.fieldInput}
|
||||
value={draft}
|
||||
maxLength={1000}
|
||||
onFocus={() => setFocused(true)}
|
||||
onChange={(e) => setDraft(e.currentTarget.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
e.currentTarget.blur();
|
||||
} else if (e.key === "Escape") {
|
||||
cancelRef.current = true;
|
||||
e.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
aria-label={property.name}
|
||||
/>
|
||||
{linkHref && (
|
||||
<a
|
||||
href={linkHref}
|
||||
target={property.type === "url" ? "_blank" : undefined}
|
||||
rel="noopener noreferrer"
|
||||
className={classes.fieldTrailing}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
aria-label={property.type === "email" ? `Email ${text}` : `Open ${text}`}
|
||||
>
|
||||
{property.type === "email" ? (
|
||||
<IconMail size={14} />
|
||||
) : (
|
||||
<IconExternalLink size={14} />
|
||||
)}
|
||||
</a>
|
||||
)}
|
||||
</FieldShell>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user