@cosmicdrift/kumiko-renderer 0.386.0 → 0.387.0
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.
- package/LICENSE +21 -57
- package/dist/app/action-form-body.d.ts +2 -2
- package/dist/app/action-form-body.js +2 -2
- package/dist/app/action-form-shim.d.ts +8 -8
- package/dist/app/action-form-shim.js +20 -22
- package/dist/app/app-features-context.js +4 -4
- package/dist/app/column-renderers.d.ts +7 -9
- package/dist/app/column-renderers.js +15 -18
- package/dist/app/config-edit-body.js +1 -1
- package/dist/app/config-edit-shim.d.ts +7 -8
- package/dist/app/config-edit-shim.js +18 -20
- package/dist/app/custom-screens.d.ts +3 -3
- package/dist/app/custom-screens.js +8 -8
- package/dist/app/dashboard-body.d.ts +2 -2
- package/dist/app/dashboard-body.js +2 -2
- package/dist/app/entity-edit-prefill.js +9 -9
- package/dist/app/entity-edit-screen.js +4 -4
- package/dist/app/entity-list-screen.js +3 -3
- package/dist/app/extension-form-submit.d.ts +1 -1
- package/dist/app/extension-form-submit.js +14 -15
- package/dist/app/extension-sections.d.ts +24 -25
- package/dist/app/extension-sections.js +10 -10
- package/dist/app/feature-schema.js +11 -11
- package/dist/app/form-schema.js +1 -1
- package/dist/app/header-actions-bar.js +1 -1
- package/dist/app/kumiko-screen.js +8 -10
- package/dist/app/layout-fields.js +1 -1
- package/dist/app/list-facets.js +3 -3
- package/dist/app/nav.d.ts +10 -10
- package/dist/app/nav.js +13 -13
- package/dist/app/projection-detail-body.js +4 -4
- package/dist/app/projection-detail-shim.d.ts +6 -5
- package/dist/app/projection-detail-shim.js +23 -22
- package/dist/app/projection-list-body.js +10 -10
- package/dist/app/projection-list-shim.d.ts +5 -5
- package/dist/app/projection-list-shim.js +17 -18
- package/dist/app/qn.js +10 -12
- package/dist/app/qualify-screen-id.js +1 -1
- package/dist/app/screen-access.js +2 -2
- package/dist/app/screen-shared.js +1 -1
- package/dist/app/secret-mint-body.js +4 -4
- package/dist/app/write-failed-error.js +8 -7
- package/dist/components/embedded-list-field.js +4 -4
- package/dist/components/grid-cell-for-field.d.ts +1 -1
- package/dist/components/reference-create-dialog.js +3 -3
- package/dist/components/related-list-section.d.ts +1 -1
- package/dist/components/related-list-section.js +1 -1
- package/dist/components/render-edit-logic.js +1 -1
- package/dist/components/render-edit-types.d.ts +6 -6
- package/dist/components/render-edit.js +63 -65
- package/dist/components/render-field.d.ts +7 -7
- package/dist/components/render-field.js +57 -59
- package/dist/components/render-list.d.ts +42 -43
- package/dist/components/render-list.js +63 -67
- package/dist/components/use-wizard-form-draft.js +10 -10
- package/dist/context/dispatcher-context.js +5 -5
- package/dist/context/user-roles-context.js +2 -2
- package/dist/headless/apex/index.d.ts +1 -2
- package/dist/headless/apex/index.js +5 -6
- package/dist/headless/form/form-controller.js +6 -6
- package/dist/headless/form/zod-bridge.js +2 -2
- package/dist/headless/format/money.js +1 -1
- package/dist/headless/index.js +2 -2
- package/dist/headless/view-model/edit.js +2 -2
- package/dist/headless/view-model/list.js +3 -4
- package/dist/headless/view-model/types.d.ts +3 -3
- package/dist/hooks/reference-limits.js +11 -12
- package/dist/hooks/use-list-url-state.d.ts +13 -13
- package/dist/hooks/use-list-url-state.js +21 -22
- package/dist/hooks/use-query.js +3 -3
- package/dist/hooks/use-reference-lookup.js +16 -17
- package/dist/hooks/use-store.js +18 -22
- package/dist/hooks/use-stream-handler.js +4 -2
- package/dist/i18n-defaults.js +2 -8
- package/dist/i18n.d.ts +10 -10
- package/dist/i18n.js +25 -25
- package/dist/index.js +5 -4
- package/dist/live/dispatcher-live.js +2 -2
- package/dist/primitives.d.ts +225 -221
- package/dist/primitives.js +1 -1
- package/dist/sse/live-events.d.ts +5 -5
- package/dist/sse/live-events.js +17 -17
- package/dist/tokens.d.ts +8 -8
- package/dist/tokens.js +16 -17
- package/dist/web/app/browser-locale.d.ts +9 -9
- package/dist/web/app/browser-locale.js +19 -19
- package/dist/web/app/client-plugin.d.ts +44 -52
- package/dist/web/app/client-plugin.js +4 -5
- package/dist/web/app/create-app.d.ts +28 -31
- package/dist/web/app/create-app.js +21 -22
- package/dist/web/app/document-lang-sync.js +1 -1
- package/dist/web/app/draft-storage.js +1 -1
- package/dist/web/app/nav-providers-context.d.ts +3 -3
- package/dist/web/app/nav-providers-context.js +3 -3
- package/dist/web/app/nav.d.ts +18 -17
- package/dist/web/app/nav.js +66 -64
- package/dist/web/app/rich-content-editor.js +1 -1
- package/dist/web/components/config-cascade.js +17 -18
- package/dist/web/components/config-source-badge.js +4 -4
- package/dist/web/layout/app-layout.d.ts +9 -9
- package/dist/web/layout/app-layout.js +2 -2
- package/dist/web/layout/avatar.d.ts +4 -3
- package/dist/web/layout/avatar.js +15 -17
- package/dist/web/layout/default-app-shell.d.ts +22 -23
- package/dist/web/layout/editor-panel.js +3 -3
- package/dist/web/layout/nav-tree.js +86 -84
- package/dist/web/layout/profile-menu.d.ts +1 -1
- package/dist/web/layout/sidebar-brand.d.ts +7 -7
- package/dist/web/layout/sidebar-user.d.ts +1 -1
- package/dist/web/layout/sidebar-user.js +4 -4
- package/dist/web/layout/sidebar.d.ts +8 -8
- package/dist/web/layout/sidebar.js +7 -7
- package/dist/web/layout/target-resolver-stub.d.ts +5 -5
- package/dist/web/layout/target-resolver-stub.js +20 -20
- package/dist/web/layout/target-url.d.ts +7 -7
- package/dist/web/layout/target-url.js +22 -22
- package/dist/web/layout/theme-toggle.js +4 -5
- package/dist/web/layout/workspace-shell.d.ts +21 -21
- package/dist/web/layout/workspace-shell.js +29 -30
- package/dist/web/lib/cn.d.ts +4 -4
- package/dist/web/lib/cn.js +4 -4
- package/dist/web/primitives/action-menu.d.ts +8 -8
- package/dist/web/primitives/action-menu.js +14 -13
- package/dist/web/primitives/calendar-popover.d.ts +1 -1
- package/dist/web/primitives/calendar-popover.js +10 -10
- package/dist/web/primitives/card-styles.js +1 -1
- package/dist/web/primitives/card.js +2 -2
- package/dist/web/primitives/combobox.d.ts +12 -12
- package/dist/web/primitives/combobox.js +34 -35
- package/dist/web/primitives/data-table-cell.js +2 -2
- package/dist/web/primitives/data-table.js +2 -3
- package/dist/web/primitives/date-field.js +2 -2
- package/dist/web/primitives/date-input.js +4 -4
- package/dist/web/primitives/default-form.js +2 -2
- package/dist/web/primitives/dropdown-menu.js +14 -15
- package/dist/web/primitives/embedded-list-input.js +3 -3
- package/dist/web/primitives/facet-filter.js +1 -1
- package/dist/web/primitives/field.js +9 -8
- package/dist/web/primitives/form.d.ts +1 -1
- package/dist/web/primitives/form.js +6 -6
- package/dist/web/primitives/input.js +2 -2
- package/dist/web/primitives/layout-elements.js +1 -1
- package/dist/web/primitives/layout.js +2 -2
- package/dist/web/primitives/located-timestamp-input.js +4 -4
- package/dist/web/primitives/money-input.js +13 -17
- package/dist/web/primitives/pager.js +1 -1
- package/dist/web/primitives/row-actions.js +3 -4
- package/dist/web/primitives/secret-reveal.js +1 -1
- package/dist/web/primitives/timestamp-input.d.ts +9 -9
- package/dist/web/primitives/timestamp-input.js +21 -21
- package/dist/web/primitives/toast.js +12 -13
- package/dist/web/sse/live-events.d.ts +6 -6
- package/dist/web/sse/live-events.js +3 -3
- package/dist/web/tokens.d.ts +11 -12
- package/dist/web/tokens.js +35 -37
- package/dist/web/version/update-checker.js +14 -13
- package/dist/web/widgets/ai-text-field.js +1 -1
- package/dist/web/widgets/charts.d.ts +10 -10
- package/dist/web/widgets/charts.js +8 -8
- package/dist/web/widgets/collapsible-section.d.ts +3 -3
- package/dist/web/widgets/collapsible-section.js +6 -6
- package/dist/web/widgets/detail-list.d.ts +1 -3
- package/dist/web/widgets/detail-list.js +1 -3
- package/dist/web/widgets/feed-list.d.ts +2 -2
- package/dist/web/widgets/feed-list.js +2 -2
- package/dist/web/widgets/form-fields.d.ts +14 -14
- package/dist/web/widgets/form-fields.js +14 -13
- package/dist/web/widgets/index.js +4 -4
- package/dist/web/widgets/infinity-list.js +3 -3
- package/dist/web/widgets/mode-switch.d.ts +2 -2
- package/dist/web/widgets/mode-switch.js +2 -2
- package/dist/web/widgets/progress-list.d.ts +1 -2
- package/dist/web/widgets/progress-list.js +1 -2
- package/dist/web/widgets/query-table.d.ts +6 -6
- package/dist/web/widgets/result-panel.d.ts +16 -16
- package/dist/web/widgets/result-panel.js +10 -10
- package/dist/web/widgets/section-card.d.ts +3 -3
- package/dist/web/widgets/section-card.js +3 -3
- package/dist/web/widgets/stat.d.ts +4 -4
- package/dist/web/widgets/stat.js +6 -6
- package/dist/web/widgets/states.d.ts +4 -4
- package/dist/web/widgets/states.js +4 -4
- package/dist/web/widgets/use-draft.d.ts +7 -7
- package/dist/web/widgets/use-draft.js +7 -7
- package/package.json +6 -6
|
@@ -20,27 +20,27 @@ type ComboboxBaseProps = {
|
|
|
20
20
|
readonly placeholder?: string;
|
|
21
21
|
readonly searchPlaceholder?: string;
|
|
22
22
|
readonly emptyText?: string;
|
|
23
|
-
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
|
|
23
|
+
/** When set, enables remote search. cmdk's client-side filter is disabled
|
|
24
|
+
* because the server has already filtered the results, and the search input
|
|
25
|
+
* invokes onSearchChange with a 300ms debounce. Without this callback,
|
|
26
|
+
* filtering remains local using cmdk's fuzzy matching on the loaded options.
|
|
27
|
+
*/
|
|
28
28
|
readonly onSearchChange?: (q: string) => void;
|
|
29
|
-
/**
|
|
29
|
+
/** Shows a spinner in the trigger and popover footer while remote search is
|
|
30
|
+
* in progress. */
|
|
30
31
|
readonly loading?: boolean;
|
|
31
32
|
/** Fixed footer row below the option list ("+ createLabel"). Rendered
|
|
32
33
|
* unfiltered — stays visible even when the search matches nothing,
|
|
33
34
|
* which is the most common case for reference-lookups on a freshly
|
|
34
|
-
* seeded system
|
|
35
|
+
* seeded system. Omit for the plain
|
|
35
36
|
* select-existing combobox. */
|
|
36
37
|
readonly onCreate?: () => void;
|
|
37
38
|
/** Label for the create-footer row. Default: i18n `kumiko.actions.create`. */
|
|
38
39
|
readonly createLabel?: string;
|
|
39
|
-
/** Test
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
* den Default (false). */
|
|
40
|
+
/** Test hook that controls the popover's initial open state. In jsdom with
|
|
41
|
+
* Radix Popover, userEvent.click does not reliably trigger PointerEvents;
|
|
42
|
+
* tests set defaultOpen=true so the popover is rendered immediately.
|
|
43
|
+
* Production code leaves the default as false. */
|
|
44
44
|
readonly defaultOpen?: boolean;
|
|
45
45
|
};
|
|
46
46
|
export type ComboboxInputProps = ComboboxBaseProps & ({
|
|
@@ -1,18 +1,17 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
//
|
|
2
|
+
// Searchable Select / Combobox.
|
|
3
3
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// cmdk
|
|
4
|
+
// The pattern follows the shadcn standard: a popover trigger rendered as a
|
|
5
|
+
// button showing the current value or placeholder, with a popover containing
|
|
6
|
+
// a cmdk command with a search input and filterable item list.
|
|
7
7
|
//
|
|
8
|
-
// cmdk
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
// MVP — kommt später als zweiter Mode-Schalter.
|
|
8
|
+
// cmdk provides client-side fuzzy matching through `Command.Filter` by
|
|
9
|
+
// default. Server-side remote search with a debounced query is not included
|
|
10
|
+
// in the MVP and can be enabled later as a separate mode.
|
|
12
11
|
//
|
|
13
|
-
// Multi-
|
|
14
|
-
//
|
|
15
|
-
//
|
|
12
|
+
// Multi-mode: `multiple: true` switches to tag display and multi-selection.
|
|
13
|
+
// Selected values render as small chips, while the search input remains open
|
|
14
|
+
// for additional selections.
|
|
16
15
|
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
|
17
16
|
import { Command } from "cmdk";
|
|
18
17
|
import { Check, ChevronDown, Loader2, Plus } from "lucide-react";
|
|
@@ -31,23 +30,24 @@ const popoverContentClass = "z-50 w-[var(--radix-popover-trigger-width)] rounded
|
|
|
31
30
|
"data-[state=open]:fade-in-0";
|
|
32
31
|
const tagClass = "inline-flex items-center gap-1 rounded-sm bg-muted px-2 py-0.5 text-xs " +
|
|
33
32
|
"font-medium text-muted-foreground";
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
// Substring
|
|
37
|
-
// `command-score` (fuzzy),
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
33
|
+
// The debounce default is centralized in hooks/reference-limits.ts so the
|
|
34
|
+
// same typing interval applies throughout the app.
|
|
35
|
+
// Substring filter for cmdk's local mode. cmdk's default is
|
|
36
|
+
// `command-score` (fuzzy), which matches "Ad" against "Backend" because 'a'
|
|
37
|
+
// and 'd' occur somewhere in the text. That is misleading for reference
|
|
38
|
+
// lookups, where users expect prefix or substring matching rather than
|
|
39
|
+
// subsequence matching.
|
|
40
|
+
// Returns 1 when search occurs in value or keywords, and 0 otherwise;
|
|
41
|
+
// cmdk hides items with a score of 0.
|
|
42
42
|
function substringFilter(value, search, keywords) {
|
|
43
43
|
const needle = search.toLowerCase();
|
|
44
44
|
return [value, ...(keywords ?? [])].some((text) => text.toLowerCase().includes(needle)) ? 1 : 0;
|
|
45
45
|
}
|
|
46
46
|
export function ComboboxInput(props) {
|
|
47
47
|
const { id, name, options, disabled, required, hasError, ariaLabel, placeholder, searchPlaceholder, emptyText, onSearchChange, loading, defaultOpen, onCreate, createLabel, } = props;
|
|
48
|
-
// i18n
|
|
49
|
-
//
|
|
50
|
-
//
|
|
48
|
+
// i18n defaults come from the framework bundle (kumikoDefaultTranslations).
|
|
49
|
+
// A caller override takes precedence, followed by the bundle override;
|
|
50
|
+
// the raw key is used only when neither is available.
|
|
51
51
|
const t = useTranslation();
|
|
52
52
|
const effectivePlaceholder = placeholder ?? t("kumiko.combobox.placeholder");
|
|
53
53
|
const effectiveSearchPlaceholder = searchPlaceholder ?? t("kumiko.combobox.search-placeholder");
|
|
@@ -56,10 +56,10 @@ export function ComboboxInput(props) {
|
|
|
56
56
|
const effectiveCreateLabel = createLabel ?? t("kumiko.actions.create");
|
|
57
57
|
const multiple = props.multiple === true;
|
|
58
58
|
const [open, setOpen] = useState(defaultOpen === true);
|
|
59
|
-
// Local
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
59
|
+
// Local search buffer for remote mode. Keystrokes are passed to
|
|
60
|
+
// onSearchChange after a 300ms debounce, avoiding a server round-trip for
|
|
61
|
+
// every keystroke. In local mode, this state is unused because cmdk
|
|
62
|
+
// controls its search input internally.
|
|
63
63
|
const [searchTerm, setSearchTerm] = useState("");
|
|
64
64
|
const isRemote = onSearchChange !== undefined;
|
|
65
65
|
useEffect(() => {
|
|
@@ -68,15 +68,14 @@ export function ComboboxInput(props) {
|
|
|
68
68
|
const timer = setTimeout(() => onSearchChange(searchTerm), REFERENCE_SEARCH_DEBOUNCE_MS);
|
|
69
69
|
return () => clearTimeout(timer);
|
|
70
70
|
}, [isRemote, searchTerm, onSearchChange]);
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
// den Server-State stale halten zwischen Aufrufen.
|
|
71
|
+
// Reset the search term when the popover closes so the next opening does not
|
|
72
|
+
// retain the previous term and leave the server state stale between uses.
|
|
74
73
|
useEffect(() => {
|
|
75
74
|
if (!open && searchTerm !== "")
|
|
76
75
|
setSearchTerm("");
|
|
77
76
|
}, [open, searchTerm]);
|
|
78
|
-
//
|
|
79
|
-
//
|
|
77
|
+
// The discriminated union lets TypeScript narrow props.value and onChange
|
|
78
|
+
// automatically, so no runtime cast is needed.
|
|
80
79
|
const selectedValues = multiple ? new Set(props.value) : new Set();
|
|
81
80
|
const singleValue = !multiple ? props.value : "";
|
|
82
81
|
const singleLabel = options.find((o) => o.value === singleValue)?.label ?? "";
|
|
@@ -93,10 +92,10 @@ export function ComboboxInput(props) {
|
|
|
93
92
|
};
|
|
94
93
|
const triggerLabel = multiple ? null : singleLabel === "" ? placeholder : singleLabel;
|
|
95
94
|
return (_jsxs(PopoverPrimitive.Root, { open: open, onOpenChange: setOpen, children: [_jsx("input", { type: "hidden", name: name, value: multiple ? props.value.join(",") : props.value }), _jsxs(PopoverPrimitive.Trigger, { id: id, "data-testid": `combobox-${id}`, type: "button", disabled: disabled, "aria-required": required, "aria-invalid": hasError === true ? true : undefined, "aria-label": ariaLabel, "aria-haspopup": "listbox", "aria-expanded": open, title: !multiple && singleLabel !== "" ? singleLabel : undefined, className: cn(triggerClass, hasError === true && "border-destructive focus-visible:ring-destructive"), children: [multiple ? (_jsx("span", { className: "flex flex-wrap items-center gap-1", children: selectedValues.size === 0 ? (_jsx("span", { className: "text-muted-foreground", children: effectivePlaceholder })) : (
|
|
96
|
-
// Tags
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
95
|
+
// Tags are read-only because the trigger is a <button> and cannot contain a
|
|
96
|
+
// nested <button>. Removing a value therefore requires selecting
|
|
97
|
+
// it again in the combobox popup, where the item toggle deselects
|
|
98
|
+
// the corresponding option.
|
|
100
99
|
[...selectedValues].map((v) => {
|
|
101
100
|
const opt = options.find((o) => o.value === v);
|
|
102
101
|
return (_jsx("span", { className: tagClass, children: opt?.label ?? v }, v));
|
|
@@ -152,7 +152,7 @@ function isMoneyValue(value) {
|
|
|
152
152
|
//
|
|
153
153
|
// - boolean -> check mark / empty
|
|
154
154
|
// - timestamp/date -> locale-formatted (no raw ISO string)
|
|
155
|
-
// - number/decimal/bigInt → locale-formatted via Intl.NumberFormat
|
|
155
|
+
// - number/decimal/bigInt → locale-formatted via Intl.NumberFormat
|
|
156
156
|
// - select/multiSelect → human-readable (kebab-case → Title Case), multiSelect values joined with ", "
|
|
157
157
|
// - money → { amount, currency } formatted via Intl (not "[object Object]")
|
|
158
158
|
// - text/else -> toString
|
|
@@ -390,7 +390,7 @@ export function DataTableCell({ value, row, field, type, renderer, optionLabels,
|
|
|
390
390
|
// A select value renders as a pill instead of plain text. When the raw
|
|
391
391
|
// value is a known status word, it carries the same tone as the
|
|
392
392
|
// projectionDetail header badge — a status column stayed grey while the
|
|
393
|
-
// detail view of the same value was coloured
|
|
393
|
+
// detail view of the same value was coloured. Unknown values
|
|
394
394
|
// keep the neutral outline pill.
|
|
395
395
|
if (type === "select" && value !== null && value !== undefined && value !== "") {
|
|
396
396
|
return selectPill(value, optionLabels, optionTones, locale, RegisteredStatusBadge);
|
|
@@ -58,13 +58,12 @@ function showFullTextOnHover(event) {
|
|
|
58
58
|
element.title = element.scrollWidth > element.clientWidth ? (element.textContent ?? "") : "";
|
|
59
59
|
}
|
|
60
60
|
export function DefaultDataTable({ columns, rows, onRowClick, sort, onSortChange, emptyState, toolbarStart, toolbarDescription, toolbarEnd, pager, onReachEnd, loadingMore, hasMore, itemNoun, rowActions, rowActionMode, rowGrouping, rowTone, filterFacets, filterValues, onFilterChange, onFilterReset, dateRangeFacets, onDateRangeChange, testId, onCellChange, getRowTestId, getCellTestId, chromeless, scrollBody, screenPadding, expandedRowIds, onToggleRowExpanded, renderExpandedRow, }) {
|
|
61
|
-
// One locale/translate subscription per table — not per cell
|
|
61
|
+
// One locale/translate subscription per table — not per cell.
|
|
62
62
|
// Optional hooks: a bare DataTable outside LocaleProvider must not crash.
|
|
63
63
|
const tableTranslate = useOptionalTranslation();
|
|
64
64
|
const tableLocale = useOptionalLocale();
|
|
65
65
|
// Below 768px a table scrolls its columns out of reach with no visible
|
|
66
|
-
// affordance
|
|
67
|
-
// one). Cards replace the table entirely below the breakpoint — same
|
|
66
|
+
// affordance. Cards replace the table entirely below the breakpoint — same
|
|
68
67
|
// single-mount pattern as EmbeddedListInput/embedded-list-input.tsx.
|
|
69
68
|
const isNarrow = useIsNarrowViewport();
|
|
70
69
|
const [facetsOpenNarrow, setFacetsOpenNarrow] = useState(false);
|
|
@@ -6,7 +6,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
6
6
|
// One source for both date primitives: DateInput (kind:"date") is a thin
|
|
7
7
|
// re-export of this, TimestampInput (kind:"timestamp") uses it as the
|
|
8
8
|
// date part next to the time input. So `date` and `timestamp` share the
|
|
9
|
-
// same typing/navigation UX instead of two diverging primitives
|
|
9
|
+
// same typing/navigation UX instead of two diverging primitives.
|
|
10
10
|
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
11
11
|
import { useState } from "react";
|
|
12
12
|
import { useTranslation } from "../../index.js";
|
|
@@ -57,7 +57,7 @@ export function DateField({ id, name, value, onChange, disabled, required, hasEr
|
|
|
57
57
|
// the app's UI locale via t() — a caller rendering a date in a
|
|
58
58
|
// locale that differs from the UI locale gets a mixed-locale
|
|
59
59
|
// placeholder (e.g. German UI + locale="en-US" → "MM/TT/JJJJ").
|
|
60
|
-
// Accepted for now
|
|
60
|
+
// Accepted for now: a static per-locale letter lookup
|
|
61
61
|
// would need its own locale-root resolution and tests for a
|
|
62
62
|
// placeholder-only, Low-severity mismatch.
|
|
63
63
|
placeholder: formatDatePlaceholder(resolvedLocale, {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// DateInput (kind:"date") —
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// date-field.tsx,
|
|
1
|
+
// DateInput (kind:"date") — the typeable date input lives in DateField,
|
|
2
|
+
// so `date` and `timestamp` share the same typing/calendar UX.
|
|
3
|
+
// This file only provides the public mapping; the logic is in
|
|
4
|
+
// date-field.tsx, and the date parsing utilities are in date-parse.ts.
|
|
5
5
|
export { DateField as DateInput } from "./date-field.js";
|
|
@@ -49,7 +49,7 @@ export function DefaultForm({ onSubmit, focusFirstErrorKey, children, title, sub
|
|
|
49
49
|
// it above the viewport edge; the shell shrinks (`flex-1`) to make room.
|
|
50
50
|
const pinnedFooter = isPinnedFooter(stickyActions, fillHeight);
|
|
51
51
|
const pinnedShellClassName = pinnedFooter ? "h-auto flex-1 pb-6" : undefined;
|
|
52
|
-
// Tab content
|
|
52
|
+
// Tab content: the head card is the ONLY card on the
|
|
53
53
|
// screen, so a tabbed projectionDetail's tab content must render as a
|
|
54
54
|
// sibling of that card, not nest another one around itself — chromeless
|
|
55
55
|
// skips the card wrapper and its border/padding so sections and footer
|
|
@@ -71,7 +71,7 @@ export function DefaultForm({ onSubmit, focusFirstErrorKey, children, title, sub
|
|
|
71
71
|
// FormScreenShell ancestor is itself height-constrained and
|
|
72
72
|
// shrinks it back down via min-h-0 — flex-1 would instead force
|
|
73
73
|
// it to always fill the remaining height, stretching a short
|
|
74
|
-
// relatedList tab to the bottom of the panel
|
|
74
|
+
// relatedList tab to the bottom of the panel.
|
|
75
75
|
// The min-height floor keeps the card from collapsing to ~0 on short
|
|
76
76
|
// viewports; the shell then overflows and <main> scrolls instead of
|
|
77
77
|
// the overflow-hidden card clipping the table.
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
// shadcn-style DropdownMenu
|
|
3
|
-
// @radix-ui/react-dropdown-menu
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// Separator
|
|
2
|
+
// shadcn-style DropdownMenu built on Radix. Wrappers around
|
|
3
|
+
// @radix-ui/react-dropdown-menu use the same Tailwind token classes as
|
|
4
|
+
// the other primitives. The caller provides the trigger-button styling
|
|
5
|
+
// through the asChild pattern; this module supplies only Content, Item,
|
|
6
|
+
// Label, Separator, and CheckboxItem with consistent styling.
|
|
7
7
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
8
|
+
// Advantages over the custom useDropdownMenu hook:
|
|
9
|
+
// - Click-outside, Escape, focus trapping, roving tabindex, ARIA roles,
|
|
10
|
+
// and pointer-versus-keyboard subtleties are handled by Radix.
|
|
11
|
+
// - Portaled content renders across stacking-context boundaries, which is
|
|
12
|
+
// useful in dialogs and popovers.
|
|
13
|
+
// - Keyboard navigation (↑↓ + Home/End) works by default.
|
|
14
14
|
import * as Primitive from "@radix-ui/react-dropdown-menu";
|
|
15
15
|
import { Check } from "lucide-react";
|
|
16
16
|
import { cn } from "../lib/cn.js";
|
|
@@ -33,10 +33,9 @@ const itemClass = "relative flex cursor-default select-none items-center gap-2 r
|
|
|
33
33
|
export function DropdownMenuItem({ className, ...props }) {
|
|
34
34
|
return _jsx(Primitive.Item, { className: cn(itemClass, className), ...props });
|
|
35
35
|
}
|
|
36
|
-
// CheckboxItem
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
// nötig.
|
|
36
|
+
// CheckboxItem with a check icon on the left for "selected item in a list"
|
|
37
|
+
// patterns (TenantSwitcher, LanguageSwitcher). Radix renders
|
|
38
|
+
// <ItemIndicator> only when checked=true, so no separate branch is needed.
|
|
40
39
|
export function DropdownMenuCheckboxItem({ className, children, checked, ...props }) {
|
|
41
40
|
return (_jsxs(Primitive.CheckboxItem, { checked: checked, className: cn(itemClass, "pl-8 pr-2", className), ...props, children: [_jsx("span", { className: "absolute left-2 flex h-3.5 w-3.5 items-center justify-center", children: _jsx(Primitive.ItemIndicator, { children: _jsx(Check, { className: "h-3.5 w-3.5" }) }) }), children] }));
|
|
42
41
|
}
|
|
@@ -8,7 +8,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
8
8
|
// Only one of the two layouts (table for md+, cards below md) is mounted
|
|
9
9
|
// at a time, picked via useIsNarrowViewport — mounting both and toggling
|
|
10
10
|
// with `hidden`/`md:hidden` left two live inputs per cell sharing one DOM
|
|
11
|
-
// id
|
|
11
|
+
// id. The `hidden md:block` / `md:hidden` classes stay on the
|
|
12
12
|
// wrapper divs as a defensive fallback (e.g. SSR/no-JS, where
|
|
13
13
|
// getServerSnapshot forces the desktop layout) even though
|
|
14
14
|
// useIsNarrowViewport — unlike the vendored useIsMobile it replaced —
|
|
@@ -211,7 +211,7 @@ function RowActions({ rowIndex, rowsLength, minItems, maxItems, onDuplicateRow,
|
|
|
211
211
|
}
|
|
212
212
|
export function EmbeddedListInput({ id, columns, rows, totals, currency, disabled, fixedRows, minItems, maxItems, listIssues, rowIssues, cellIssues, onCellChange, onAddRow, onRemoveRow, onDuplicateRow, onMoveRow, onPasteCells, addLabel, actionsLabel, removeLabel, duplicateLabel, moveUpLabel, moveDownLabel, emptyLabel, emptyCtaLabel, testId, }) {
|
|
213
213
|
// Callers that don't wire up the field-currency plumbing (or direct
|
|
214
|
-
// callers/tests
|
|
214
|
+
// callers/tests) keep getting the same "EUR" this
|
|
215
215
|
// component always hardcoded.
|
|
216
216
|
const effectiveCurrency = currency ?? "EUR";
|
|
217
217
|
const t = useTranslation();
|
|
@@ -231,7 +231,7 @@ export function EmbeddedListInput({ id, columns, rows, totals, currency, disable
|
|
|
231
231
|
// notice the desktop table remounting with a fresh DOM node.
|
|
232
232
|
const [scrollContainerEl, setScrollContainerEl] = useState(null);
|
|
233
233
|
// Resetting scrollLeft here is what guarantees the table always opens
|
|
234
|
-
// scrolled fully left
|
|
234
|
+
// scrolled fully left: browsers still auto-scroll a focused
|
|
235
235
|
// descendant into view even inside an overflow-x-auto container, but
|
|
236
236
|
// that's now a real, user-drivable scroll instead of an invisible one.
|
|
237
237
|
useEffect(() => {
|
|
@@ -28,7 +28,7 @@ export function FacetFilter({ facet, selected, onChange, dense = false, }) {
|
|
|
28
28
|
}
|
|
29
29
|
return (_jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsxs(UiButton, { variant: "outline", size: "sm", className: cn("h-9", dense && "h-8 border-border-strong px-2.5 max-md:h-11"), "data-testid": `facet-${facet.field}`, children: [facet.label, selected.length > 0 && (_jsx(Badge, { variant: "secondary", className: "ml-1 rounded-sm px-1 font-normal tabular-nums", children: selected.length })), _jsx(ChevronDown, { className: "text-muted-foreground" })] }) }), _jsx(DropdownMenuContent, { align: "start", className: "max-h-[var(--radix-dropdown-menu-content-available-height)] w-44 overflow-y-auto", children: facet.options.map((opt) => (_jsx(DropdownMenuCheckboxItem, { checked: selected.includes(opt.value), onCheckedChange: (checked) => toggle(opt.value, checked), onSelect: (e) => e.preventDefault(), "data-testid": `facet-${facet.field}-${opt.value}`, children: opt.label }, opt.value))) })] }));
|
|
30
30
|
}
|
|
31
|
-
// Time-range filter
|
|
31
|
+
// Time-range filter: two native <input type="date"> instead of a
|
|
32
32
|
// dropdown — the browser supplies the calendar, the locale and the keyboard
|
|
33
33
|
// handling, so no date dependency is needed. Each bound constrains the other
|
|
34
34
|
// through min/max so the picker can't offer an inverted span; the caller
|
|
@@ -81,10 +81,11 @@ const FIELD_ICONS = {
|
|
|
81
81
|
key: KeyRound,
|
|
82
82
|
"map-pin": MapPin,
|
|
83
83
|
};
|
|
84
|
+
function isFieldIconKey(key) {
|
|
85
|
+
return Object.hasOwn(FIELD_ICONS, key);
|
|
86
|
+
}
|
|
84
87
|
export function fieldIconFor(icon) {
|
|
85
|
-
return icon !== undefined &&
|
|
86
|
-
? FIELD_ICONS[icon]
|
|
87
|
-
: undefined;
|
|
88
|
+
return icon !== undefined && isFieldIconKey(icon) ? FIELD_ICONS[icon] : undefined;
|
|
88
89
|
}
|
|
89
90
|
// Wraps a text/number input with a left-positioned prefix icon when
|
|
90
91
|
// `icon` carries a known FIELD_ICONS key. `pl-8` overrides (via
|
|
@@ -127,7 +128,7 @@ export function withTrailingAction(action, input, keepWrapper = false) {
|
|
|
127
128
|
// Default presentation for `kind: "select"` without a `display` of its own
|
|
128
129
|
// (board rule): up to 3 short options read as a segmented control, up to 3
|
|
129
130
|
// longer ones as a vertical radio list, everything else as a dropdown. Labels
|
|
130
|
-
// arrive translated, so the widget can differ between UI languages
|
|
131
|
+
// arrive translated, so the widget can differ between UI languages.
|
|
131
132
|
const SEGMENTED_SELECT_MAX_OPTIONS = 3;
|
|
132
133
|
const SEGMENTED_SELECT_MAX_LABEL_LENGTH = 16;
|
|
133
134
|
export function defaultSelectPresentation(options) {
|
|
@@ -138,9 +139,9 @@ export function defaultSelectPresentation(options) {
|
|
|
138
139
|
: "radioList";
|
|
139
140
|
}
|
|
140
141
|
// A "" value is the unselected placeholder, not a real choice — a radio
|
|
141
|
-
// group can't render an unchecked-everything state as its own segment
|
|
142
|
-
//
|
|
143
|
-
//
|
|
142
|
+
// group can't render an unchecked-everything state as its own segment.
|
|
143
|
+
// Filtered once so the eligibility count and the rendered segments never drift
|
|
144
|
+
// apart.
|
|
144
145
|
export function withoutSegmentedSelectPlaceholder(options) {
|
|
145
146
|
return options.filter((option) => option.value !== "");
|
|
146
147
|
}
|
|
@@ -162,7 +163,7 @@ export function RadioListSelect({ id, name, value, onChange, options, variant =
|
|
|
162
163
|
: "flex items-start gap-2 text-sm font-normal text-foreground has-[:disabled]:opacity-50", children: [_jsx("input", { type: "radio", name: name, value: opt.value, checked: opt.value === value, disabled: disabled === true || opt.disabled === true, "aria-describedby": opt.description !== undefined ? descriptionIdOf(opt) : undefined, "data-testid": `radio-list-${id}-${opt.value}`, onChange: () => onChange(opt.value), className: cn("size-4 accent-primary", card ? "mt-0.5 shrink-0" : "mt-0.5") }), _jsxs("span", { className: "flex flex-col gap-0.5", children: [_jsx("span", { className: cn(card && "font-semibold"), children: opt.label }), opt.description !== undefined && (_jsx("span", { id: descriptionIdOf(opt), className: "text-[13px] text-foreground-secondary", children: opt.description }))] })] }, opt.value)))] }, section.heading ?? ""))) }));
|
|
163
164
|
}
|
|
164
165
|
// The vendored shadcn Textarea carries `field-sizing: content`, which derives
|
|
165
|
-
// the box height from the content and makes the `rows` attribute inert
|
|
166
|
+
// the box height from the content and makes the `rows` attribute inert.
|
|
166
167
|
// A declared row count therefore only survives as a min-height floor: the field
|
|
167
168
|
// starts at `rows` lines tall and still grows with its content. The addend is
|
|
168
169
|
// the textarea frame — py-2 top+bottom plus the 1px borders.
|
|
@@ -47,7 +47,7 @@ export declare function FormScreenShell({ children, className, testId, maxWidth,
|
|
|
47
47
|
readonly maxWidth?: FormScreenShellWidth;
|
|
48
48
|
/** Stacks children in a `h-full` flex column instead of normal document
|
|
49
49
|
* flow, so a `flex-1 min-h-0` child can claim the remaining height below
|
|
50
|
-
* the others (
|
|
50
|
+
* the others (DefaultForm's tabs+relatedList case). Default
|
|
51
51
|
* false: unchanged, content-sized height. */
|
|
52
52
|
readonly fillHeight?: boolean;
|
|
53
53
|
}): ReactNode;
|
|
@@ -11,11 +11,11 @@ import "../react-css-custom-properties.js";
|
|
|
11
11
|
import { cardFooterBorder, cardHeaderBorder } from "./card-styles.js";
|
|
12
12
|
import { InsideFormContext, ScreenFormContext, ScreenWidthContext } from "./contexts.js";
|
|
13
13
|
// Extension sections that render their own <form> via BareFormProvider
|
|
14
|
-
// (legacy custom-form pattern, e.g. ChangeEmailSection
|
|
14
|
+
// (legacy custom-form pattern, e.g. ChangeEmailSection) land
|
|
15
15
|
// inside RenderEdit's host <form> (ExtensionSectionMount, render-edit.tsx) —
|
|
16
16
|
// nested <form> elements are invalid DOM, and real browsers can silently
|
|
17
17
|
// fall back to a native GET submit of the OUTER form, leaking field values
|
|
18
|
-
// into the URL
|
|
18
|
+
// into the URL. Degrading to a <div> when already inside
|
|
19
19
|
// a form avoids the nesting; the captured click still routes to THIS form's
|
|
20
20
|
// onSubmit instead of activating the real ancestor <form>.
|
|
21
21
|
const SUBMIT_CONTROL_SELECTOR = [
|
|
@@ -95,7 +95,7 @@ export function FormSections({ children, chromeless, stickyActions, fillHeight,
|
|
|
95
95
|
// Margins, not padding: padding would land inside a child that has its own border/height.
|
|
96
96
|
!chromeless && "[&>:not(section)]:mx-6 [&>:not(section)]:my-3", !chromeless && "[&>:not(section):first-child]:mt-6 [&>:not(section):last-child]:mb-6",
|
|
97
97
|
// ponytail: fixed footer now pins only the primary action (single
|
|
98
|
-
// button row + its own p-4
|
|
98
|
+
// button row + its own p-4) instead of the whole footer —
|
|
99
99
|
// shrunk from pb-32 accordingly. Widen again if a wizard's primary
|
|
100
100
|
// action ever wraps to two rows.
|
|
101
101
|
stickyActions === true && fillHeight !== true && STICKY_FOOTER_SPACER_CLASS,
|
|
@@ -117,7 +117,7 @@ export function FormTitleBlock({ title, subtitle, titleAction, testId, bordered,
|
|
|
117
117
|
}
|
|
118
118
|
// `formActions` (render-edit.tsx) mixes a non-submit wizard Back button in
|
|
119
119
|
// with the submit-type Next/Finish button inside one Fragment — only the
|
|
120
|
-
// submit one is the "primary" action that
|
|
120
|
+
// submit one is the "primary" action that needs pinned above a
|
|
121
121
|
// virtual keyboard. Back reads as `type="button"`, same as `secondaryActions`
|
|
122
122
|
// (Cancel/Delete/…), so it can safely join that group on mobile. A footer-slot
|
|
123
123
|
// mount can't expose `type="submit"` (its button is opaque app code), so it
|
|
@@ -162,7 +162,7 @@ export function FormFooter({ actions, secondaryActions, testId, chromeless, stic
|
|
|
162
162
|
const hasPrimaryAction = primaryActionNodes !== undefined && primaryActionNodes.length > 0;
|
|
163
163
|
// No primary node found among `actions` (e.g. a wizard step with only
|
|
164
164
|
// secondary buttons): fall back to pinning the whole, unpartitioned
|
|
165
|
-
// `actions` node instead of splitting it — matches the
|
|
165
|
+
// `actions` node instead of splitting it — matches the previous
|
|
166
166
|
// behaviour where sticky footers always kept `actions` fixed.
|
|
167
167
|
const nonPrimaryActionNodes = hasPrimaryAction
|
|
168
168
|
? actionNodes?.filter((node) => !isPrimaryStickyAction(node))
|
|
@@ -196,7 +196,7 @@ export function FormFooter({ actions, secondaryActions, testId, chromeless, stic
|
|
|
196
196
|
: "flex-wrap max-sm:w-full max-sm:[&>button]:flex-1 max-sm:[&>button]:min-h-11 sm:ml-auto",
|
|
197
197
|
// Below sm (640px): pin only the primary action to the viewport
|
|
198
198
|
// bottom instead of normal flow, so a virtual keyboard shrinking
|
|
199
|
-
// the viewport can't push it out of reach
|
|
199
|
+
// the viewport can't push it out of reach. `fixed`
|
|
200
200
|
// escapes the card's `overflow-hidden` (only transform/filter/
|
|
201
201
|
// contain ancestors trap it, confirmed against
|
|
202
202
|
// AppLayout/SidebarInset — neither sets those).
|
|
@@ -86,7 +86,7 @@ export function DefaultInput(props) {
|
|
|
86
86
|
const radioGroupOptions = props.display === "radio" ? comboOptions : withoutSegmentedSelectPlaceholder(comboOptions);
|
|
87
87
|
// An explicit `display` is an author decision and outranks the
|
|
88
88
|
// heuristic in both directions — a requested radio group renders as
|
|
89
|
-
// one even when the options outnumber the heuristic's threshold
|
|
89
|
+
// one even when the options outnumber the heuristic's threshold.
|
|
90
90
|
const requestedPresentation = props.radioVariant === "card"
|
|
91
91
|
? "radioList"
|
|
92
92
|
: props.display === "radio"
|
|
@@ -107,7 +107,7 @@ export function DefaultInput(props) {
|
|
|
107
107
|
return (_jsx(ComboboxInput, { id: props.id, name: props.name, value: props.value, onChange: props.onChange, options: dropdownOptions, ...(props.disabled !== undefined && { disabled: props.disabled }), ...(props.required !== undefined && { required: props.required }), ...(props.hasError !== undefined && { hasError: props.hasError }) }));
|
|
108
108
|
}
|
|
109
109
|
case "combobox": {
|
|
110
|
-
//
|
|
110
|
+
// Discriminated union per `multiple`; we split
|
|
111
111
|
// TS-side into two branches so ComboboxInput's single/multi variants
|
|
112
112
|
// render with the right types.
|
|
113
113
|
const baseProps = {
|
|
@@ -48,7 +48,7 @@ export function DefaultSection({ id, title, subtitle, children, actions, variant
|
|
|
48
48
|
export function DefaultFillContainer({ children, testId, grow }) {
|
|
49
49
|
// flex-1 only on request (`grow`, set under fillScreenHeight): without it the
|
|
50
50
|
// container sizes to its content and only shrinks (min-h-0), so a two-row
|
|
51
|
-
// table in a non-fixed-height screen does not stretch
|
|
51
|
+
// table in a non-fixed-height screen does not stretch.
|
|
52
52
|
return (_jsx("div", { "data-testid": testId, className: cn("flex min-h-0 flex-col", grow === true && "flex-1"), children: children }));
|
|
53
53
|
}
|
|
54
54
|
const DRAWER_FIELD_CELL_WIDTH_CLASS = {
|
|
@@ -2,7 +2,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
2
2
|
import { cn } from "../lib/cn.js";
|
|
3
3
|
// Shared 3xl/4xl/full → max-w-*/mx-auto map. FormScreenShell
|
|
4
4
|
// (primitives/index.tsx) reuses this instead of keeping its own copy —
|
|
5
|
-
// same width tokens must render the same class everywhere
|
|
5
|
+
// same width tokens must render the same class everywhere.
|
|
6
6
|
export const screenWidthClassName = {
|
|
7
7
|
"3xl": "max-w-3xl mx-auto",
|
|
8
8
|
"4xl": "max-w-4xl mx-auto",
|
|
@@ -19,7 +19,7 @@ export const screenFormColumnWidthClassName = {
|
|
|
19
19
|
// FormScreenShell, list screens and the pinned-footer screen form). The inset
|
|
20
20
|
// is the one the settings screens use, so a list, a dashboard and a form start
|
|
21
21
|
// at the same edge. The bottom inset keeps the last field or table row off the
|
|
22
|
-
// viewport edge
|
|
22
|
+
// viewport edge.
|
|
23
23
|
export const screenPaddingClassName = "px-4 pb-6 pt-5 md:px-10 md:pb-10 md:pt-7";
|
|
24
24
|
const STACK_GAP = { sm: "gap-2", md: "gap-4", lg: "gap-6" };
|
|
25
25
|
export function Stack({ gap = "md", className, children, testId }) {
|
|
@@ -7,10 +7,10 @@ export function LocatedTimestampInput({ id, name, value, onChange, locale, min,
|
|
|
7
7
|
const t = useTranslation();
|
|
8
8
|
const at = value === "" ? "" : value.at;
|
|
9
9
|
const tz = value === "" ? "" : value.tz;
|
|
10
|
-
// `at`
|
|
11
|
-
// tz
|
|
12
|
-
//
|
|
13
|
-
//
|
|
10
|
+
// `at` is a minute-precision wall-clock value (z.iso.datetime({local:true}) accepts it);
|
|
11
|
+
// tz is an IANA name. The field is empty while both are empty; once either
|
|
12
|
+
// is set, the pair is emitted — Required/Zod validation on submit enforces
|
|
13
|
+
// completeness (so a partial input remains visible).
|
|
14
14
|
function emit(nextAt, nextTz) {
|
|
15
15
|
if (nextAt === "" && nextTz === "") {
|
|
16
16
|
onChange(undefined);
|
|
@@ -1,21 +1,18 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
// MoneyInput
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// Symbol (€/$/¥) UND Tausender-Trenner UND korrekte Decimals in einem
|
|
7
|
-
// Aufruf.
|
|
2
|
+
// MoneyInput uses locale-aware formatting while preserving minor units (cents) as the canonical value.
|
|
3
|
+
// On focus, it shows a raw editable decimal string; on blur, Intl.NumberFormat with
|
|
4
|
+
// `style: "currency"` supplies the currency symbol (€/$/¥), grouping separators, and
|
|
5
|
+
// correct decimal places in one call.
|
|
8
6
|
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
// trotzdem.
|
|
7
|
+
// `type=number` cannot accept formatted strings such as ("1.234,56 €") or locale-specific
|
|
8
|
+
// decimal separators such as commas. `inputMode="decimal"` still provides a mobile numeric
|
|
9
|
+
// keypad.
|
|
13
10
|
import { useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
14
11
|
import { currencyDecimals, formatMoney as formatMoneyForLocale } from "../../headless/index.js";
|
|
15
12
|
import { cn } from "../lib/cn.js";
|
|
16
13
|
import { parseLocaleNumber, resolveSafeLocale } from "./locale-number.js";
|
|
17
14
|
// Re-exported for backward compat — callers used to import this from here
|
|
18
|
-
// before it moved to headless (shared with RenderField
|
|
15
|
+
// before it moved to headless (shared with RenderField).
|
|
19
16
|
export { currencyDecimals, parseLocaleNumber };
|
|
20
17
|
// The border and focus ring live on the wrapper so the currency symbol can be
|
|
21
18
|
// a flex sibling of the input: a symbol of any width ("€", "CHF", "R$") takes
|
|
@@ -24,22 +21,21 @@ const wrapperClass = "flex h-9 w-full items-center rounded-md border border-inpu
|
|
|
24
21
|
"transition-colors focus-within:ring-1 focus-within:ring-ring";
|
|
25
22
|
const inputClass = "h-full min-w-0 flex-1 bg-transparent px-3 placeholder:text-muted-foreground " +
|
|
26
23
|
"focus-visible:outline-none disabled:cursor-not-allowed " +
|
|
27
|
-
//
|
|
28
|
-
//
|
|
24
|
+
// Right-aligns the value like a native type=number input so amounts align at the thousands
|
|
25
|
+
// separators in list columns.
|
|
29
26
|
"text-right tabular-nums";
|
|
30
27
|
export function MoneyInput({ id, name, value, onChange, currency, locale, disabled, required, hasError, ariaLabel, }) {
|
|
31
28
|
const decimals = currencyDecimals(currency);
|
|
32
29
|
const factor = 10 ** decimals;
|
|
33
30
|
const resolvedLocale = resolveSafeLocale(locale);
|
|
34
31
|
const [focused, setFocused] = useState(false);
|
|
35
|
-
//
|
|
36
|
-
// Math.round
|
|
32
|
+
// Keeps edits raw while focused; otherwise each keystroke would round-trip through
|
|
33
|
+
// Math.round and formatting, causing the cursor to jump.
|
|
37
34
|
const [draft, setDraft] = useState("");
|
|
38
35
|
const inputRef = useRef(null);
|
|
39
36
|
const major = value === "" ? null : value / factor;
|
|
40
37
|
const { symbol, symbolPosition, formatNumber } = useMemo(() => moneyFormatParts(currency, resolvedLocale), [currency, resolvedLocale]);
|
|
41
38
|
const formatted = value === "" ? "" : formatNumber(value / factor);
|
|
42
|
-
// Edit-Mode: Decimal-String ohne Tausender-Trenner.
|
|
43
39
|
const toEditable = (m) => m === null
|
|
44
40
|
? ""
|
|
45
41
|
: m.toLocaleString(resolvedLocale, {
|
|
@@ -58,7 +54,7 @@ export function MoneyInput({ id, name, value, onChange, currency, locale, disabl
|
|
|
58
54
|
// to the *end* of the new value rather than preserving position — so
|
|
59
55
|
// without an explicit re-select, typing right after focus appends
|
|
60
56
|
// instead of replacing. That's what silently corrupted values set via
|
|
61
|
-
// Playwright's `.fill()
|
|
57
|
+
// Playwright's `.fill()`. It also matches standard
|
|
62
58
|
// money-input UX (immediate overtype on click), and sidesteps mapping a
|
|
63
59
|
// click position from the formatted view to the editable one, which has
|
|
64
60
|
// no well-defined equivalent once separators and the currency symbol
|
|
@@ -20,7 +20,7 @@ export const TABLE_FOOTER_BAR_CLASS = cn("flex h-15 shrink-0 items-center gap-4
|
|
|
20
20
|
// `screen.pageSize ?? 50`) rather than the real per-screen pageSize, which isn't
|
|
21
21
|
// threaded down to this component. A screen with a custom pageSize can still
|
|
22
22
|
// under- or over-show the marker; thread pageSize through KumikoScreen →
|
|
23
|
-
// RenderList → DataTable → InfiniteSentinel if that's needed
|
|
23
|
+
// RenderList → DataTable → InfiniteSentinel if that's needed.
|
|
24
24
|
export const END_LABEL_MIN_ROWS = 50;
|
|
25
25
|
export function InfiniteSentinel({ onReachEnd, loadingMore, hasMore, rowsCount, testId, }) {
|
|
26
26
|
const t = useTranslation();
|