UNPKG

@octopusdeploy/design-system-components

Version:
151 lines (150 loc) 9.29 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); Object.defineProperty(exports, "__esModule", { value: true }); exports.Timespan = Timespan; const jsx_runtime_1 = require("react/jsx-runtime"); const css_1 = require("@emotion/css"); const design_system_tokens_1 = require("@octopusdeploy/design-system-tokens"); const React = __importStar(require("react")); const FormDescription_1 = require("../Primitives/FormDescription"); const Input_1 = require("../Primitives/Input"); const InputValidationMessage_1 = require("../Primitives/InputValidationMessage"); const Legend_1 = require("../Primitives/Legend"); const formFieldMaxWidth_1 = require("../formFieldMaxWidth"); const parseHelpers_1 = require("../utils/parseHelpers"); // Constraints mirror the existing DeprecatedTimeSpanSelector: hours are 0-23 and minutes/seconds 0-59, // matching .NET TimeSpan segment semantics. const segmentDefinitions = { days: { label: "Days", unit: "days", min: 0 }, hours: { label: "Hours", unit: "hrs", min: 0, max: 23 }, minutes: { label: "Minutes", unit: "min", min: 0, max: 59 }, seconds: { label: "Seconds", unit: "sec", min: 0, max: 59 }, }; const unitSegments = { "day-hour-minute": ["days", "hours", "minutes"], "hour-minute-second": ["hours", "minutes", "seconds"], "minute-second": ["minutes", "seconds"], }; // The leading (largest) unit in a layout is the overflow container, so it is never capped; trailing // units keep their natural modulus (days has no max regardless). Mirrors .NET TimeSpan semantics: // only the largest component may exceed the next unit's rollover. function maxForSegment(key, leadingSegment) { return key === leadingSegment ? undefined : segmentDefinitions[key].max; } // Segments only accept whole, non-negative numbers, so we block the characters that // native `<input type="number">` otherwise allows: sign and scientific-notation // characters (`-`, `+`, `e`, `E`) and decimal separators (`.`, `,`). const blockedSegmentKeys = ["e", "E", "+", "-", ".", ","]; /** * Timespan for entering a duration across day/hour/minute/second segments. * * @remarks Only use in pre-approved areas, as in #project-form-uplift if unsure before using. * * @param props - TimespanProps * @param props.label - The label for the field, rendered as the group legend * @param props.value - The current value of the field * @param props.units - Which segments to display (defaults to "day-hour-minute") * @param props.description - The description text to display below the label * @param props.validationMessage - Validation message to display * @param props.disabled - Whether the field is disabled * @param props.readOnly - Whether the inputs are read only * @param props.autoFocus - Whether to autofocus the first segment * @param props.hasRequiredMarker - Whether the field is required * @param props.hasOptionalMarker - Whether to show the optional marker * @param props.popover - PopoverBasicHelp component to display additional help information * @param props.name - The name attribute, used to build stable element ids * @param props.onChange - The action to perform when any segment changes * * @returns Timespan component */ function Timespan({ label, value, onChange, units = "day-hour-minute", description, validationMessage, disabled = false, readOnly = false, autoFocus = false, hasRequiredMarker = false, hasOptionalMarker = false, popover, name, }) { const baseId = React.useId(); // React useId adds colons which are not valid in css custom properties or ids used by the Input primitive const fieldId = `timespanfield-${name ? name + "-" : ""}${baseId}`.replace(/[:;]/g, ""); const descriptionId = `${fieldId}-description`; const validationId = `${fieldId}-validation`; const validationDisplayState = validationMessage ? "error" : "none"; const segments = unitSegments[units]; const leadingSegment = segments[0]; const handleSegmentChange = React.useCallback((key, newValue) => { const parsed = newValue ? (0, parseHelpers_1.safeParseFloat)(String(newValue), 0) : undefined; // Keep persisted values to whole numbers within the segment's bounds even if a value // sneaks past the keydown filter (e.g. via paste): drop any fractional part, clamp to // the min (always 0), and clamp to the max — except the leading unit, which is the // overflow container and stays unbounded. let parsedNumber = undefined; if (parsed !== undefined) { const max = maxForSegment(key, leadingSegment); const lowerBounded = Math.max(segmentDefinitions[key].min, Math.trunc(parsed)); parsedNumber = max === undefined ? lowerBounded : Math.min(max, lowerBounded); } onChange({ ...value, [key]: parsedNumber }); }, [onChange, value, leadingSegment]); const handleSegmentKeyDown = React.useCallback((event) => { if (blockedSegmentKeys.includes(event.key)) { event.preventDefault(); } }, []); const describedBy = [description ? descriptionId : null, validationMessage ? validationId : null].filter(Boolean).join(" ") || undefined; return ((0, jsx_runtime_1.jsxs)("fieldset", { className: fieldsetStyles, disabled: disabled, "aria-describedby": describedBy, children: [(0, jsx_runtime_1.jsx)(Legend_1.Legend, { label: label, hasOptionalMarker: hasOptionalMarker, hasRequiredMarker: hasRequiredMarker, popover: popover, disabled: disabled, marginSize: "small" }), description && (0, jsx_runtime_1.jsx)(FormDescription_1.FormDescription, { id: descriptionId, description: description, isDisabled: disabled, isGroupLabel: true }), (0, jsx_runtime_1.jsx)("div", { className: inputsRowStyles, children: segments.map((key, index) => { const segment = segmentDefinitions[key]; // Segments stay "none" so they keep a neutral border and aren't individually marked // invalid: the field validates as a whole and surfaces its error via the message // below, which stays linked to the group and inputs through aria-describedby. return ((0, jsx_runtime_1.jsx)("div", { className: segmentStyles, children: (0, jsx_runtime_1.jsx)(Input_1.Input, { id: `${fieldId}-${key}`, type: "number", // Default to "" so the input is controlled from first render; passing undefined would // make it uncontrolled until a value is typed, triggering React's controlled/uncontrolled // warning. Use ?? (not ||) so a legitimate 0 isn't replaced with an empty input. value: value[key] ?? "", min: segment.min, max: maxForSegment(key, leadingSegment), step: 1, disabled: disabled, readOnly: readOnly, autoFocus: autoFocus && index === 0, name: name ? `${name}-${key}` : undefined, "aria-label": segment.label, suffix: segment.unit, "aria-describedby": describedBy, validationDisplayState: "none", onKeyDown: handleSegmentKeyDown, onChange: (newValue) => handleSegmentChange(key, newValue) }) }, key)); }) }), validationMessage && ((0, jsx_runtime_1.jsx)("div", { className: validationContainerStyles, children: (0, jsx_runtime_1.jsx)(InputValidationMessage_1.InputValidationMessage, { message: validationMessage, displayState: validationDisplayState, id: validationId }) }))] })); } exports.default = Timespan; const fieldsetStyles = (0, css_1.css)({ display: "grid", border: design_system_tokens_1.borderWidth.none, margin: 0, padding: 0, maxWidth: formFieldMaxWidth_1.formFieldMaxWidth, }); const inputsRowStyles = (0, css_1.css)({ display: "flex", flexWrap: "wrap", gap: design_system_tokens_1.space[12], alignItems: "center", }); const segmentStyles = (0, css_1.css)({ width: "100px" }); const validationContainerStyles = (0, css_1.css)({ marginTop: design_system_tokens_1.space[8], });