@octopusdeploy/design-system-components
Version:
The design systems component library.
151 lines (150 loc) • 9.29 kB
JavaScript
;
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],
});