@flood-md/react 0.2.11 → 0.2.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,32 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { forwardRef, useMemo, useState } from "react";
3
- import { Table, Checkbox, Button, IconSlot, VisuallyHidden, Pagination, Text, Spinner, Alert, Stack } from "./index.js";
1
+ import { createElement as _createElement } from "react";
2
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { forwardRef, useEffect, useMemo, useRef, useState } from "react";
4
+ import { Table, Checkbox, Button, Badge, IconSlot, VisuallyHidden, Pagination, Text, Spinner, Alert, Stack, Inline, Menu, Tooltip } from "./index.js";
5
+ import { useDataTableColumnLayout } from "./data-table-columns.js";
6
+ import { DataTableExpandableRow, useDataTableExpansion } from "./data-table-expansion.js";
7
+ const columnName = (column) => column.unit ? `${column.label} ${column.unit}` : column.label;
8
+ function DataTableHeading({ column, sortLabel, disabled, descending, onSort }) {
9
+ const label = useRef(null);
10
+ const [truncated, setTruncated] = useState(false);
11
+ useEffect(() => {
12
+ const node = label.current;
13
+ if (!node)
14
+ return;
15
+ const measure = () => setTruncated(node.scrollWidth > node.clientWidth);
16
+ measure();
17
+ const Observer = node.ownerDocument.defaultView?.ResizeObserver;
18
+ const observer = Observer && new Observer(measure);
19
+ observer?.observe(node);
20
+ const fonts = node.ownerDocument.fonts;
21
+ fonts?.addEventListener("loadingdone", measure);
22
+ return () => {
23
+ observer?.disconnect();
24
+ fonts?.removeEventListener("loadingdone", measure);
25
+ };
26
+ }, [column.label, column.unit]);
27
+ const content = _jsxs(_Fragment, { children: [_jsx("span", { ref: label, className: "flood-data-table__header-label", children: column.label }), " ", column.unit && _jsx(Badge, { size: "sm", variant: "outline", className: "flood-data-table__header-unit", "aria-hidden": "true", children: column.unit })] });
28
+ return _jsx(Tooltip, { content: columnName(column), disabled: !truncated, trigger: trigger => column.sortable ? _jsxs(Button, { ...trigger, "aria-describedby": undefined, variant: "ghost", size: "sm", disabled: disabled, className: "flood-data-table__sort", "aria-label": sortLabel, onClick: onSort, children: [content, _jsx(IconSlot, { size: "sm", children: _jsx("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.75", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: descending ? _jsx("path", { d: "m7 14 5-5 5 5" }) : _jsx("path", { d: "m8 8 4-4 4 4M8 16l4 4 4-4" }) }) })] }) : _jsx("span", { ...trigger, "aria-describedby": undefined, className: "flood-data-table__header-content", tabIndex: truncated ? 0 : undefined, children: content }) });
29
+ }
4
30
  function activatePrimaryRowAction(event) {
5
31
  const target = event.target;
6
32
  const row = event.currentTarget;
@@ -19,7 +45,7 @@ function activatePrimaryRowAction(event) {
19
45
  button.focus({ preventScroll: true });
20
46
  button.click();
21
47
  }
22
- export const ManagedDataTable = /* @__PURE__ */ forwardRef(function ManagedDataTable({ caption, hideCaption = false, columns, rows, density = "comfortable", striped = false, minWidth = "40rem", toolbar, inset = "standard", sort: controlledSort, defaultSort = null, onSortChange, locale = "en", selectable = false, selectedIds, defaultSelectedIds = [], onSelectionChange, page: controlledPage, defaultPage = 1, pageSize = 10, onPageChange, showResults = true, paginationMode = "client", totalRows, selectionScope, state = "ready", onRetry, emptyContent, labels = {}, className, ...props }, ref) {
48
+ export const ManagedDataTable = /* @__PURE__ */ forwardRef(function ManagedDataTable({ caption, hideCaption = false, columns, rows, density = "comfortable", striped = false, minWidth = "40rem", toolbar, inset = "standard", maxViewportHeight, stickyHeader, showColumnVisibility = false, hiddenColumnIds, defaultHiddenColumnIds = [], onHiddenColumnIdsChange, columnWidths, defaultColumnWidths, onColumnWidthsChange, expandedIds, defaultExpandedIds, onExpandedChange, sort: controlledSort, defaultSort = null, onSortChange, locale = "en", selectable = false, selectedIds, defaultSelectedIds = [], onSelectionChange, page: controlledPage, defaultPage = 1, pageSize = 10, onPageChange, showResults = true, showSelection = true, paginationMode = "client", totalRows, selectionScope, state = "ready", onRetry, emptyContent, labels = {}, className, ...props }, ref) {
23
49
  if (!caption.trim() || columns.length === 0 || columns.some(column => !column.id.trim() || !column.label.trim()) || new Set(columns.map(column => column.id)).size !== columns.length)
24
50
  throw new Error("Flood DataTable requires a caption and unique named columns");
25
51
  if (rows.some(row => !row.id.trim() || (row.label !== undefined && !row.label.trim())) || new Set(rows.map(row => row.id)).size !== rows.length)
@@ -39,6 +65,55 @@ export const ManagedDataTable = /* @__PURE__ */ forwardRef(function ManagedDataT
39
65
  const [localSort, setSort] = useState(defaultSort), [localSelected, setSelected] = useState(defaultSelectedIds), [localPage, setPage] = useState(defaultPage);
40
66
  const requestedSort = controlledSort === undefined ? localSort : controlledSort;
41
67
  const sorting = requestedSort && columns.some(column => column.id === requestedSort.column && column.sortable) ? requestedSort : null;
68
+ const [localHiddenIds, setHiddenIds] = useState(defaultHiddenColumnIds);
69
+ const hidden = new Set(hiddenColumnIds ?? localHiddenIds);
70
+ const required = new Set(rows.flatMap(row => row.primaryAction ? [row.primaryAction.column] : []));
71
+ if (sorting)
72
+ required.add(sorting.column);
73
+ const displayed = columns.filter(column => column.hideable === false || required.has(column.id) || !hidden.has(column.id));
74
+ const retained = displayed.length > 0 ? displayed : columns.slice(0, 1);
75
+ const visibleColumns = [
76
+ ...retained.filter(column => column.pinned === "start"),
77
+ ...retained.filter(column => !column.pinned),
78
+ ...retained.filter(column => column.pinned === "end"),
79
+ ];
80
+ const expansion = useDataTableExpansion(rows, expandedIds, defaultExpandedIds, onExpandedChange);
81
+ const columnLayout = useDataTableColumnLayout(columns, visibleColumns, {
82
+ columnWidths, defaultColumnWidths, onColumnWidthsChange, measureViewport: expansion.enabled,
83
+ });
84
+ const columnCount = visibleColumns.length + Number(selectable) + Number(expansion.enabled);
85
+ const visibleIds = new Set(visibleColumns.map(column => column.id));
86
+ const normalizeHiddenIds = (ids) => columns.filter(column => column.hideable !== false && ids.has(column.id)).map(column => column.id);
87
+ const defaultIds = normalizeHiddenIds(new Set(defaultHiddenColumnIds));
88
+ const requestedIds = normalizeHiddenIds(hidden);
89
+ const visibilityAtDefaults = requestedIds.length === defaultIds.length && defaultIds.every(id => hidden.has(id));
90
+ const changeHiddenIds = (next) => {
91
+ if (hiddenColumnIds === undefined)
92
+ setHiddenIds(next);
93
+ onHiddenColumnIdsChange?.(next);
94
+ };
95
+ const columnItems = showColumnVisibility ? [
96
+ ...columns.map((column) => ({
97
+ id: `column:${column.id}`,
98
+ type: "checkbox",
99
+ label: sorting?.column === column.id
100
+ ? (labels.sortedColumn ?? (label => `${label} (sorted)`))(columnName(column))
101
+ : columnName(column),
102
+ checked: visibleIds.has(column.id),
103
+ disabled: column.hideable === false || required.has(column.id) || (visibleColumns.length === 1 && visibleIds.has(column.id)),
104
+ onCheckedChange: checked => {
105
+ const next = new Set(hidden);
106
+ if (checked)
107
+ next.delete(column.id);
108
+ else
109
+ next.add(column.id);
110
+ changeHiddenIds(normalizeHiddenIds(next));
111
+ },
112
+ })),
113
+ { id: "column-layout-separator", type: "separator" },
114
+ { id: "reset-columns", label: labels.resetColumns ?? "Reset columns",
115
+ disabled: visibilityAtDefaults && columnLayout.atDefaults },
116
+ ] : [];
42
117
  const sorted = useMemo(() => {
43
118
  if (server || !sorting)
44
119
  return [...rows];
@@ -86,8 +161,26 @@ export const ManagedDataTable = /* @__PURE__ */ forwardRef(function ManagedDataT
86
161
  changePage(1);
87
162
  };
88
163
  const resultLabel = labels.results ?? ((from, to, total) => `${from}–${to} of ${total}`);
89
- return _jsxs("div", { ...props, ref: ref, className: ["flood-data-table", className].filter(Boolean).join(" "), "data-inset": inset, "aria-busy": state === "loading" || undefined, children: [toolbar != null && _jsx("div", { className: "flood-data-table__toolbar", children: toolbar }), _jsxs(Table, { caption: caption, hideCaption: hideCaption, density: density, striped: striped, style: { minWidth }, children: [_jsx("thead", { children: _jsxs("tr", { children: [selectable && _jsx("th", { scope: "col", className: "flood-data-table__choice", children: _jsx(Checkbox, { label: _jsx(VisuallyHidden, { children: labels.selectPage ?? "Select rows on this page" }), checked: all, indeterminate: some && !all, disabled: blocked || pageChoices.length === 0, onChange: event => { const ids = new Set(selection); for (const row of pageChoices)
90
- event.currentTarget.checked ? ids.add(row.id) : ids.delete(row.id); select([...ids]); } }) }), columns.map(column => _jsx("th", { scope: "col", "data-numeric": column.numeric || undefined, "data-align": column.align, "data-sortable": column.sortable || undefined, "aria-sort": sorting?.column === column.id ? sorting.direction : undefined, style: { width: column.width }, children: column.sortable ? _jsxs(Button, { variant: "ghost", size: "sm", disabled: blocked, className: "flood-data-table__sort", "aria-label": (labels.sort ?? ((label) => `Sort by ${label}`))(column.label), onClick: () => changeSort(column.id), children: [column.label, _jsx(IconSlot, { size: "sm", children: _jsx("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.75", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: sorting?.column === column.id ? _jsx("path", { d: "m7 14 5-5 5 5" }) : _jsx("path", { d: "m8 8 4-4 4 4M8 16l4 4 4-4" }) }) })] }) : column.label }, column.id))] }) }), _jsx("tbody", { children: state === "ready" && visible.length > 0 ? visible.map(row => _jsxs("tr", { "data-selected": selectable && chosen.has(row.id) || undefined, "data-primary-action": row.primaryAction && !row.primaryAction.buttonProps.disabled && row.primaryAction.buttonProps["aria-disabled"] !== "true" || undefined, onClick: row.primaryAction ? activatePrimaryRowAction : undefined, children: [selectable && _jsx("td", { className: "flood-data-table__choice", children: _jsx(Checkbox, { label: _jsx(VisuallyHidden, { children: (labels.selectRow ?? ((label) => `Select ${label}`))(row.label ?? row.id) }), checked: chosen.has(row.id), disabled: Boolean(row.selectionDisabled), onChange: event => select(event.currentTarget.checked ? [...selection, row.id] : selection.filter(id => id !== row.id)) }) }), columns.map(column => _jsx("td", { "data-numeric": column.numeric || undefined, "data-align": column.align, children: row.primaryAction?.column === column.id
164
+ const selectionLabel = selectable
165
+ ? (labels.selection ?? ((count, total) => `${count} of ${total} selected`))(selection.length, server && scope === "dataset" ? total : eligible.size)
166
+ : null;
167
+ const renderToolbar = typeof toolbar === "function" ? toolbar : null;
168
+ const columnsControl = showColumnVisibility ? (_jsx(Menu, { label: labels.columns ?? "Columns", items: columnItems, align: "end", onAction: id => {
169
+ if (id !== "reset-columns")
170
+ return;
171
+ if (!visibilityAtDefaults)
172
+ changeHiddenIds(defaultIds);
173
+ columnLayout.reset();
174
+ }, trigger: trigger => _jsx(Button, { ...trigger, className: renderToolbar ? undefined : "flood-data-table__columns", variant: "subtle", size: "sm", children: labels.columns ?? "Columns" }) })) : null;
175
+ const toolbarContent = typeof toolbar === "function" ? toolbar({ columns: columnsControl }) : toolbar;
176
+ const sortLabel = labels.sort ?? ((label) => `Sort by ${label}`);
177
+ return _jsxs("div", { ...props, ref: ref, className: ["flood-data-table", className].filter(Boolean).join(" "), "data-inset": inset, "aria-busy": state === "loading" || undefined, children: [columnsControl && !renderToolbar ? _jsxs(Inline, { gap: "3", className: "flood-data-table__toolbar", children: [toolbarContent != null && _jsx("div", { className: "flood-data-table__toolbar-content", children: toolbarContent }), columnsControl] }) : toolbarContent != null && _jsx("div", { className: "flood-data-table__toolbar", children: toolbarContent }), _jsxs(Table, { ref: columnLayout.table, className: "flood-data-table__table", caption: caption, hideCaption: hideCaption, density: density, striped: striped, maxViewportHeight: maxViewportHeight, stickyHeader: stickyHeader, "data-inset": inset, "data-fixed-columns": columnLayout.fixedWidth !== undefined || undefined, style: columnLayout.fixedWidth === undefined ? { minWidth } : { minWidth, inlineSize: columnLayout.fixedWidth, tableLayout: "fixed" }, children: [_jsx("thead", { children: _jsxs("tr", { children: [expansion.enabled && _jsx("th", { ...columnLayout.utilityCell("expansion", true), scope: "col", "data-data-table-utility": "expansion", className: "flood-data-table__expand", children: _jsx(VisuallyHidden, { children: labels.rowDetails ?? "Row details" }) }), selectable && _jsx("th", { ...columnLayout.utilityCell("selection", true), scope: "col", className: "flood-data-table__choice", children: _jsx(Checkbox, { label: _jsx(VisuallyHidden, { children: labels.selectPage ?? "Select rows on this page" }), checked: all, indeterminate: some && !all, disabled: blocked || pageChoices.length === 0, onChange: event => { const ids = new Set(selection); for (const row of pageChoices)
178
+ event.currentTarget.checked ? ids.add(row.id) : ids.delete(row.id); select([...ids]); } }) }), visibleColumns.map(column => {
179
+ const name = columnName(column);
180
+ return _createElement("th", { ...columnLayout.columnCell(column, true), key: column.id, scope: "col", "data-numeric": column.numeric || undefined, "data-align": column.align, "data-sortable": column.sortable || undefined, "data-resize-edge": column.resizable ? column.pinned === "end" ? "start" : "end" : undefined, "aria-label": column.sortable ? sortLabel(name) : name, "aria-sort": sorting?.column === column.id ? sorting.direction : undefined },
181
+ _jsx(DataTableHeading, { column: column, sortLabel: sortLabel(name), disabled: blocked, descending: sorting?.column === column.id, onSort: () => changeSort(column.id) }),
182
+ column.resizable && columnLayout.renderHandle(column, (labels.resizeColumn ?? (label => `Resize ${label} column`))(name), blocked));
183
+ })] }) }), _jsx("tbody", { children: state === "ready" && visible.length > 0 ? visible.map(row => _jsx(DataTableExpandableRow, { row: row, showDisclosure: expansion.enabled, expanded: expansion.expanded.has(row.id), colSpan: columnCount, labels: labels, onToggle: () => expansion.toggle(row.id), disclosureCellProps: columnLayout.utilityCell("expansion"), viewportWidth: columnLayout.viewportWidth, disclosureWidth: columnLayout.disclosureWidth, children: disclosure => _jsxs("tr", { "data-selected": selectable && chosen.has(row.id) || undefined, "data-primary-action": row.primaryAction && !row.primaryAction.buttonProps.disabled && row.primaryAction.buttonProps["aria-disabled"] !== "true" || undefined, onClick: row.primaryAction ? activatePrimaryRowAction : undefined, children: [disclosure, selectable && _jsx("td", { ...columnLayout.utilityCell("selection"), className: "flood-data-table__choice", children: _jsx(Checkbox, { label: _jsx(VisuallyHidden, { children: (labels.selectRow ?? ((label) => `Select ${label}`))(row.label ?? row.id) }), checked: chosen.has(row.id), disabled: Boolean(row.selectionDisabled), onChange: event => select(event.currentTarget.checked ? [...selection, row.id] : selection.filter(id => id !== row.id)) }) }), visibleColumns.map(column => _createElement("td", { ...columnLayout.columnCell(column), key: column.id, "data-numeric": column.numeric || undefined, "data-align": column.align }, row.primaryAction?.column === column.id
91
184
  ? _jsxs(Stack, { gap: "1", children: [_jsx(Button, { ...row.primaryAction.buttonProps, type: "button", variant: "ghost", size: "sm", className: ["flood-data-table__primary-action", row.primaryAction.buttonProps.className].filter(Boolean).join(" "), children: row.primaryAction.title }), row.primaryAction.description != null && _jsx(Text, { as: "div", size: "helper", tone: "subtle", children: row.primaryAction.description })] })
92
- : row.cells[column.id] ?? null }, column.id))] }, row.id)) : _jsx("tr", { children: _jsx("td", { colSpan: columns.length + (selectable ? 1 : 0), className: "flood-data-table__state", children: state === "loading" ? _jsx(Spinner, { label: labels.loading ?? "Loading rows…" }) : state === "error" ? _jsxs(Stack, { gap: "3", children: [_jsx(Alert, { tone: "error", announcement: "polite", children: labels.error ?? "Could not load the rows." }), onRetry && _jsx(Button, { variant: "subtle", size: "sm", onClick: onRetry, children: labels.retry ?? "Try again" })] }) : emptyContent ?? _jsx(Text, { as: "p", size: "compact", tone: "subtle", role: "status", children: labels.empty ?? "No results found." }) }) }) })] }), (showResults || selectable || pageCount > 1) && _jsxs("div", { className: "flood-data-table__footer", children: [showResults && _jsx(Text, { as: "p", size: "helper", tone: "subtle", children: state === "ready" ? resultLabel(visible.length ? (page - 1) * pageSize + 1 : 0, visible.length ? (page - 1) * pageSize + visible.length : 0, total) : "" }), selectable && _jsx(Text, { as: "p", size: "helper", tone: "subtle", role: "status", children: (labels.selection ?? ((count, total) => `${count} of ${total} selected`))(selection.length, server && scope === "dataset" ? total : eligible.size) }), pageCount > 1 && _jsx(Pagination, { page: Math.min(page, pageCount), pageCount: pageCount, onPageChange: changePage, disabled: blocked, label: labels.pagination, previousLabel: labels.previousPage, nextLabel: labels.nextPage, pageLabel: labels.page })] })] });
185
+ : row.cells[column.id] ?? null))] }) }, row.id)) : _jsx("tr", { children: _jsx("td", { colSpan: columnCount, className: "flood-data-table__state", children: state === "loading" ? _jsx(Spinner, { label: labels.loading ?? "Loading rows…" }) : state === "error" ? _jsxs(Stack, { gap: "3", children: [_jsx(Alert, { tone: "error", announcement: "polite", children: labels.error ?? "Could not load the rows." }), onRetry && _jsx(Button, { variant: "subtle", size: "sm", onClick: onRetry, children: labels.retry ?? "Try again" })] }) : emptyContent ?? _jsx(Text, { as: "p", size: "compact", tone: "subtle", role: "status", children: labels.empty ?? "No results found." }) }) }) })] }), selectable && !showSelection && _jsx(VisuallyHidden, { role: "status", children: selectionLabel }), (showResults || (selectable && showSelection) || pageCount > 1) && _jsxs("div", { className: "flood-data-table__footer", children: [showResults && _jsx(Text, { as: "p", size: "helper", tone: "subtle", children: state === "ready" ? resultLabel(visible.length ? (page - 1) * pageSize + 1 : 0, visible.length ? (page - 1) * pageSize + visible.length : 0, total) : "" }), selectable && showSelection && _jsx(Text, { as: "p", size: "helper", tone: "subtle", role: "status", children: selectionLabel }), pageCount > 1 && _jsx(Pagination, { page: Math.min(page, pageCount), pageCount: pageCount, onPageChange: changePage, disabled: blocked, label: labels.pagination, previousLabel: labels.previousPage, nextLabel: labels.nextPage, pageLabel: labels.page })] })] });
93
186
  });
package/dist/editable.js CHANGED
@@ -1,10 +1,12 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { forwardRef, useEffect, useId, useImperativeHandle, useLayoutEffect, useRef, useState } from "react";
3
- import { Button, IconButton, IconSlot, Input, InputGroup } from "./index.js";
3
+ import { Button, IconButton, IconSlot, Input, InputGroup, Text } from "./index.js";
4
+ import { mergeDescriptions } from "./field-state.js";
5
+ import { positionLayer, registerLayer } from "./overlay-runtime.js";
4
6
  const useBrowserLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
5
7
  const cx = (...values) => values.filter(Boolean).join(" ");
6
8
  /** A draft is private until explicitly committed; native forms receive committed text. */
7
- export const InlineEditable = /* @__PURE__ */ forwardRef(function InlineEditable({ label, hideLabel = false, value, defaultValue = "", onValueChange, editing, defaultEditing = false, onEditingChange, onCommit, onCancel, blurAction = "commit", readOnly = false, disabled = false, placeholder = "Empty", controlSize = "md", showActions = true, editLabel = "Edit", saveLabel = "Save", cancelLabel = "Cancel", inputRef, name, form, id: explicitId, className, onBlur, onKeyDown, ...props }, forwardedRef) {
9
+ export const InlineEditable = /* @__PURE__ */ forwardRef(function InlineEditable({ label, hideLabel = false, value, defaultValue = "", onValueChange, editing, defaultEditing = false, onEditingChange, onCommit, onCancel, validate, inputMode, previewVariant = "subtle", editorOverlayWidth, blurAction = "commit", readOnly = false, disabled = false, placeholder = "Empty", controlSize = "md", showActions = true, editLabel = "Edit", saveLabel = "Save", cancelLabel = "Cancel", inputRef, name, form, id: explicitId, className, onBlur, onKeyDown, ...props }, forwardedRef) {
8
10
  if (![label, editLabel, saveLabel, cancelLabel].every(text => text.trim()))
9
11
  throw new Error("Flood Editable requires nonempty control and action labels");
10
12
  const initial = useRef(defaultValue), [local, setLocal] = useState(defaultValue);
@@ -12,9 +14,13 @@ export const InlineEditable = /* @__PURE__ */ forwardRef(function InlineEditable
12
14
  const [localEditing, setLocalEditing] = useState(defaultEditing);
13
15
  const requested = editing ?? localEditing, active = requested && !disabled && !readOnly;
14
16
  const [draft, setDraft] = useState(committed);
17
+ const [validationError, setValidationError] = useState();
15
18
  const root = useRef(null), editor = useRef(null), bridge = useRef(null), preview = useRef(null);
16
- const current = useRef({ committed, active, draft, value, editing, onValueChange, onEditingChange, onCommit, onCancel });
17
- current.current = { committed, active, draft, value, editing, onValueChange, onEditingChange, onCommit, onCancel };
19
+ const layer = useRef(null);
20
+ const floating = editorOverlayWidth !== undefined;
21
+ const fitDraft = editorOverlayWidth === "fit-content";
22
+ const current = useRef({ committed, active, draft, value, editing, onValueChange, onEditingChange, onCommit, onCancel, validate });
23
+ current.current = { committed, active, draft, value, editing, onValueChange, onEditingChange, onCommit, onCancel, validate };
18
24
  const composing = useRef(false), resolved = useRef(false), wasActive = useRef(false);
19
25
  const generated = useId(), id = explicitId ?? `flood-editable-${generated}`, inputId = `${id}-input`, previewId = `${id}-preview`;
20
26
  useImperativeHandle(forwardedRef, () => root.current, []);
@@ -34,8 +40,12 @@ export const InlineEditable = /* @__PURE__ */ forwardRef(function InlineEditable
34
40
  const finish = (action, restoreFocus) => {
35
41
  if (!current.current.active || resolved.current || (composing.current && action === "commit") || editor.current?.matches(":disabled"))
36
42
  return;
37
- resolved.current = true;
38
43
  const next = current.current.draft;
44
+ const message = action === "commit" ? current.current.validate?.(next) : undefined;
45
+ setValidationError(message);
46
+ if (message)
47
+ return;
48
+ resolved.current = true;
39
49
  if (action === "commit") {
40
50
  if (next !== current.current.committed) {
41
51
  if (current.current.value === undefined)
@@ -62,10 +72,35 @@ export const InlineEditable = /* @__PURE__ */ forwardRef(function InlineEditable
62
72
  // A controlled host may refuse to close and allow a later deliberate attempt.
63
73
  queueMicrotask(() => { resolved.current = false; });
64
74
  };
75
+ useBrowserLayoutEffect(() => {
76
+ const panel = layer.current, anchor = preview.current;
77
+ if (!active || !floating || !panel || !anchor)
78
+ return;
79
+ const document = panel.ownerDocument, view = document.defaultView;
80
+ panel.showPopover();
81
+ let frame = 0;
82
+ const position = () => {
83
+ positionLayer(anchor, panel, "bottom", "end", true);
84
+ frame = view.requestAnimationFrame(position);
85
+ };
86
+ position();
87
+ const unregister = registerLayer(document, {
88
+ panel, anchor: () => anchor, close: () => finish("cancel", true), closeOnFocusOutside: false,
89
+ outside: () => { if (blurAction !== "none")
90
+ finish(blurAction, false); },
91
+ });
92
+ return () => {
93
+ unregister();
94
+ view.cancelAnimationFrame(frame);
95
+ if (panel.matches(":popover-open"))
96
+ panel.hidePopover();
97
+ };
98
+ }, [active, floating, blurAction]);
65
99
  useBrowserLayoutEffect(() => {
66
100
  let frame = 0;
67
101
  if (active && !wasActive.current) {
68
102
  setDraft(current.current.committed);
103
+ setValidationError(undefined);
69
104
  resolved.current = false;
70
105
  composing.current = false;
71
106
  const view = editor.current?.ownerDocument.defaultView;
@@ -106,7 +141,20 @@ export const InlineEditable = /* @__PURE__ */ forwardRef(function InlineEditable
106
141
  return () => { owner.removeEventListener("reset", reset); clearTimeout(timer); };
107
142
  }, [form]);
108
143
  const glyph = (kind) => _jsx("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.75", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: kind === "save" ? _jsx("path", { d: "m5 12 4 4L19 6" }) : kind === "cancel" ? _jsx("path", { d: "m6 6 12 12M18 6 6 18" }) : _jsx(_Fragment, { children: _jsx("path", { d: "m15 5 4 4M4 20l4-1L20 7a2.1 2.1 0 0 0-3-3L5 16l-1 4Z" }) }) });
109
- const editorProps = { id: inputId, "aria-describedby": props["aria-describedby"], "aria-invalid": props["aria-invalid"], controlSize, disabled };
144
+ const errorId = `${inputId}-error`;
145
+ const editorProps = { id: inputId, inputMode, "aria-describedby": mergeDescriptions(props["aria-describedby"], active && validationError ? errorId : undefined),
146
+ "aria-invalid": active && validationError ? true : props["aria-invalid"], controlSize, disabled };
147
+ const editButton = _jsxs(Button, { ref: preview, id: previewId, type: "button", variant: previewVariant, size: controlSize, disabled: disabled, className: "flood-editable__preview", "aria-describedby": props["aria-describedby"], "aria-label": `${editLabel}: ${label}. ${committed || placeholder}`, "aria-hidden": active && floating || undefined, tabIndex: active && floating ? -1 : undefined, onClick: start, children: [_jsx("span", { className: "flood-editable__value", "data-empty": !committed || undefined, children: committed || placeholder }), _jsx(IconSlot, { size: controlSize, children: glyph("edit") })] });
148
+ const editorContent = active && _jsxs(_Fragment, { children: [_jsx(InputGroup, { ...editorProps, ref: editor, value: draft, placeholder: placeholder, size: fitDraft ? Math.max(4, (draft || placeholder).length + 1) : undefined, onChange: event => { setValidationError(undefined); setDraft(event.currentTarget.value); current.current.draft = event.currentTarget.value; }, onCompositionStart: () => { composing.current = true; }, onCompositionEnd: event => {
149
+ composing.current = false;
150
+ setDraft(event.currentTarget.value);
151
+ current.current.draft = event.currentTarget.value;
152
+ const document = event.currentTarget.ownerDocument;
153
+ if (blurAction !== "none" && !root.current?.contains(document.activeElement))
154
+ queueMicrotask(() => finish(blurAction, false));
155
+ }, end: showActions ? _jsxs("div", { className: "flood-editable__actions", children: [_jsx(IconButton, { type: "button", label: saveLabel, size: controlSize, variant: "ghost", onPointerDown: event => { if (event.pointerType === "mouse")
156
+ event.preventDefault(); }, onMouseDown: event => event.preventDefault(), onClick: () => finish("commit", true), children: glyph("save") }), _jsx(IconButton, { type: "button", label: cancelLabel, size: controlSize, variant: "ghost", onPointerDown: event => { if (event.pointerType === "mouse")
157
+ event.preventDefault(); }, onMouseDown: event => event.preventDefault(), onClick: () => finish("cancel", true), children: glyph("cancel") })] }) : undefined }), validationError && _jsx(Text, { as: "p", id: errorId, role: "alert", size: "helper", tone: "error", className: "flood-editable__error", children: validationError })] });
110
158
  return _jsxs("div", { ...props, ref: root, id: id, className: cx("flood-editable", className), "data-editing": active || undefined, "data-disabled": disabled || undefined, "data-readonly": readOnly || undefined, onKeyDown: event => {
111
159
  onKeyDown?.(event);
112
160
  if (event.defaultPrevented || !current.current.active || event.target !== editor.current || event.nativeEvent.isComposing || composing.current || event.keyCode === 229 || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey)
@@ -120,16 +168,7 @@ export const InlineEditable = /* @__PURE__ */ forwardRef(function InlineEditable
120
168
  if (event.defaultPrevented || !current.current.active || event.currentTarget.contains(event.relatedTarget) || blurAction === "none")
121
169
  return;
122
170
  finish(blurAction, false);
123
- }, children: [_jsx("label", { htmlFor: active || readOnly ? inputId : previewId, className: cx("flood-label", hideLabel && "flood-visually-hidden"), children: label }), _jsx("input", { ref: bridge, type: "hidden", name: name, form: form, value: committed, disabled: disabled }), active ? _jsx(InputGroup, { ...editorProps, ref: editor, value: draft, placeholder: placeholder, onChange: event => { setDraft(event.currentTarget.value); current.current.draft = event.currentTarget.value; }, onCompositionStart: () => { composing.current = true; }, onCompositionEnd: event => {
124
- composing.current = false;
125
- setDraft(event.currentTarget.value);
126
- current.current.draft = event.currentTarget.value;
127
- const document = event.currentTarget.ownerDocument;
128
- if (blurAction !== "none" && !root.current?.contains(document.activeElement))
129
- queueMicrotask(() => finish(blurAction, false));
130
- }, end: showActions ? _jsxs("div", { className: "flood-editable__actions", children: [_jsx(IconButton, { type: "button", label: saveLabel, size: controlSize, variant: "ghost", onPointerDown: event => { if (event.pointerType === "mouse")
131
- event.preventDefault(); }, onMouseDown: event => event.preventDefault(), onClick: () => finish("commit", true), children: glyph("save") }), _jsx(IconButton, { type: "button", label: cancelLabel, size: controlSize, variant: "ghost", onPointerDown: event => { if (event.pointerType === "mouse")
132
- event.preventDefault(); }, onMouseDown: event => event.preventDefault(), onClick: () => finish("cancel", true), children: glyph("cancel") })] }) : undefined })
133
- : readOnly ? _jsx(Input, { ...editorProps, ref: editor, value: committed, readOnly: true })
134
- : _jsxs(Button, { ref: preview, id: previewId, type: "button", variant: "subtle", size: controlSize, disabled: disabled, className: "flood-editable__preview", "aria-describedby": props["aria-describedby"], "aria-label": `${editLabel}: ${label}. ${committed || placeholder}`, onClick: start, children: [_jsx("span", { className: "flood-editable__value", "data-empty": !committed || undefined, children: committed || placeholder }), _jsx(IconSlot, { size: controlSize, children: glyph("edit") })] })] });
171
+ }, children: [_jsx("label", { htmlFor: active || readOnly ? inputId : previewId, className: cx("flood-label", hideLabel && "flood-visually-hidden"), children: label }), _jsx("input", { ref: bridge, type: "hidden", name: name, form: form, value: committed, disabled: disabled }), readOnly ? _jsx(Input, { ...editorProps, ref: editor, value: committed, readOnly: true }) : (!active || floating) && editButton, active && (floating
172
+ ? _jsx("div", { ref: layer, popover: "manual", className: "flood-overlay-panel flood-editable__overlay", "data-fit-draft": fitDraft || undefined, style: { inlineSize: editorOverlayWidth }, children: editorContent })
173
+ : editorContent)] });
135
174
  });