mirror of
https://github.com/OneUptime/oneuptime.git
synced 2026-04-06 08:42:13 +02:00
Compare commits
8 Commits
postmortem
...
dropdown-l
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
667bb02656 | ||
|
|
f26645901e | ||
|
|
2a2fa44c3f | ||
|
|
81de4ea7c8 | ||
|
|
9b00d54682 | ||
|
|
13a7605903 | ||
|
|
8a7413ada4 | ||
|
|
8bd67ae432 |
@@ -2,17 +2,37 @@ import ObjectID from "../../../Types/ObjectID";
|
||||
import React, {
|
||||
FunctionComponent,
|
||||
ReactElement,
|
||||
ReactNode,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import Select, { ControlProps, GroupBase, OptionProps } from "react-select";
|
||||
import Select, {
|
||||
ControlProps,
|
||||
DropdownIndicatorProps,
|
||||
FormatOptionLabelMeta,
|
||||
GroupBase,
|
||||
OptionProps,
|
||||
components as selectComponents,
|
||||
} from "react-select";
|
||||
import Pill, { PillSize } from "../Pill/Pill";
|
||||
import { Black } from "../../../Types/BrandColors";
|
||||
import Color from "../../../Types/Color";
|
||||
import IconProp from "../../../Types/Icon/IconProp";
|
||||
|
||||
export type DropdownValue = string | number | boolean;
|
||||
|
||||
export interface DropdownOptionLabel {
|
||||
id?: string | null;
|
||||
name?: string | null;
|
||||
color?: string | { toString: () => string } | null;
|
||||
slug?: string | null;
|
||||
}
|
||||
|
||||
export interface DropdownOption {
|
||||
value: DropdownValue;
|
||||
label: string;
|
||||
labels?: Array<DropdownOptionLabel> | undefined;
|
||||
}
|
||||
|
||||
export interface ComponentProps {
|
||||
@@ -37,6 +57,8 @@ export interface ComponentProps {
|
||||
const Dropdown: FunctionComponent<ComponentProps> = (
|
||||
props: ComponentProps,
|
||||
): ReactElement => {
|
||||
type LabelBadgeSize = "menu" | "value";
|
||||
|
||||
type GetDropdownOptionFromValueFunctionProps =
|
||||
| undefined
|
||||
| DropdownValue
|
||||
@@ -111,33 +133,188 @@ const Dropdown: FunctionComponent<ComponentProps> = (
|
||||
|
||||
const firstUpdate: React.MutableRefObject<boolean> = useRef(true);
|
||||
|
||||
const resolveLabelColor: (label: DropdownOptionLabel) => Color = (
|
||||
label: DropdownOptionLabel,
|
||||
): Color => {
|
||||
const labelColor: DropdownOptionLabel["color"] = label.color;
|
||||
|
||||
if (!labelColor) {
|
||||
return Black;
|
||||
}
|
||||
|
||||
if (labelColor instanceof Color) {
|
||||
return labelColor;
|
||||
}
|
||||
|
||||
if (typeof labelColor === "string") {
|
||||
try {
|
||||
return Color.fromString(labelColor);
|
||||
} catch (_) {
|
||||
return Black;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof (labelColor as any)?.toString === "function") {
|
||||
try {
|
||||
return Color.fromString((labelColor as any).toString());
|
||||
} catch (_) {
|
||||
return Black;
|
||||
}
|
||||
}
|
||||
|
||||
return Black;
|
||||
};
|
||||
|
||||
const renderLabelBadges = (
|
||||
optionLabels?: Array<DropdownOptionLabel>,
|
||||
config?: { maxVisible?: number; size?: LabelBadgeSize },
|
||||
): ReactElement | null => {
|
||||
if (!optionLabels || optionLabels.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sanitizedLabels: Array<DropdownOptionLabel> = optionLabels.filter(
|
||||
(label: DropdownOptionLabel | null | undefined) => {
|
||||
return Boolean(label && (label.name || label.slug));
|
||||
},
|
||||
) as Array<DropdownOptionLabel>;
|
||||
|
||||
if (sanitizedLabels.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const maxVisible: number = config?.maxVisible || 3;
|
||||
const size: LabelBadgeSize = config?.size || "menu";
|
||||
|
||||
const visibleLabels: Array<DropdownOptionLabel> = sanitizedLabels.slice(
|
||||
0,
|
||||
Math.max(0, maxVisible),
|
||||
);
|
||||
const hiddenCount: number = sanitizedLabels.length - visibleLabels.length;
|
||||
|
||||
const containerClassName: string =
|
||||
size === "menu"
|
||||
? "flex flex-wrap items-center gap-2"
|
||||
: "flex flex-wrap items-center gap-1.5";
|
||||
const moreLabelClassName: string =
|
||||
size === "menu"
|
||||
? "inline-flex items-center rounded-full bg-slate-50 px-2 py-0.5 text-[10px] font-semibold text-slate-500 ring-1 ring-inset ring-slate-200"
|
||||
: "inline-flex items-center rounded-full bg-slate-50 px-1.5 py-0.5 text-[9px] font-semibold text-slate-500 ring-1 ring-inset ring-slate-200";
|
||||
const pillSize: PillSize = size === "menu" ? PillSize.Normal : PillSize.Small;
|
||||
const pillStyle =
|
||||
size === "menu"
|
||||
? {
|
||||
maxWidth: "220px",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}
|
||||
: {
|
||||
maxWidth: "180px",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={containerClassName}>
|
||||
{visibleLabels.map((label: DropdownOptionLabel, index: number) => {
|
||||
const labelId: string =
|
||||
label.id?.toString() ||
|
||||
label.slug?.toString() ||
|
||||
label.name?.toString() ||
|
||||
`label-${index}`;
|
||||
|
||||
return (
|
||||
<Pill
|
||||
key={labelId}
|
||||
color={resolveLabelColor(label)}
|
||||
text={label.name || label.slug || "Label"}
|
||||
size={pillSize}
|
||||
style={pillStyle}
|
||||
icon={IconProp.Label}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{hiddenCount > 0 && (
|
||||
<span className={`${moreLabelClassName} transition-colors duration-150 ease-out hover:bg-slate-100`}>
|
||||
+{hiddenCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const formatOptionLabel = (
|
||||
option: DropdownOption,
|
||||
meta: FormatOptionLabelMeta<DropdownOption>,
|
||||
): ReactNode => {
|
||||
const labelBadges: ReactElement | null = renderLabelBadges(option.labels, {
|
||||
maxVisible: meta.context === "value" ? 2 : 3,
|
||||
size: meta.context === "value" ? "value" : "menu",
|
||||
});
|
||||
|
||||
if (meta.context === "value") {
|
||||
if (props.isMultiSelect) {
|
||||
return (
|
||||
<span className="truncate text-sm font-semibold text-slate-700">
|
||||
{option.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1 truncate">
|
||||
<span className="truncate text-sm font-semibold text-slate-900">
|
||||
{option.label}
|
||||
</span>
|
||||
{labelBadges}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1 py-1">
|
||||
<span className="text-sm font-semibold text-slate-900">
|
||||
{option.label}
|
||||
</span>
|
||||
{labelBadges}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (firstUpdate.current && props.initialValue) {
|
||||
firstUpdate.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const value: DropdownOption | Array<DropdownOption> | undefined =
|
||||
const newValue: DropdownOption | Array<DropdownOption> | undefined =
|
||||
getDropdownOptionFromValue(
|
||||
props.value === null ? undefined : props.value,
|
||||
);
|
||||
|
||||
setValue(value);
|
||||
setValue(newValue);
|
||||
}, [props.value]);
|
||||
|
||||
const containerClassName: string =
|
||||
props.className || "group relative mb-1 mt-2 w-full overflow-visible";
|
||||
|
||||
return (
|
||||
<div
|
||||
id={props.id}
|
||||
className={`${
|
||||
props.className ||
|
||||
"relative mt-2 mb-1 rounded-md w-full overflow-visible"
|
||||
}`}
|
||||
className={containerClassName}
|
||||
onClick={() => {
|
||||
props.onClick?.();
|
||||
props.onFocus?.();
|
||||
}}
|
||||
>
|
||||
<Select
|
||||
formatOptionLabel={formatOptionLabel}
|
||||
onBlur={() => {
|
||||
props.onBlur?.();
|
||||
}}
|
||||
@@ -152,23 +329,92 @@ const Dropdown: FunctionComponent<ComponentProps> = (
|
||||
control: (
|
||||
state: ControlProps<any, boolean, GroupBase<any>>,
|
||||
): string => {
|
||||
return state.isFocused
|
||||
? "!border-indigo-500"
|
||||
: "border-Gray500-300";
|
||||
const baseClasses: string =
|
||||
"!min-h-[44px] !rounded-xl !border transition-all duration-150 ease-out !shadow-sm bg-white";
|
||||
const errorClasses: string = props.error
|
||||
? "!border-red-400 !ring-2 !ring-red-100"
|
||||
: "";
|
||||
const focusClasses: string = state.isFocused
|
||||
? "!border-indigo-400 !ring-2 !ring-indigo-200/60"
|
||||
: "hover:!border-slate-300 !border-slate-200";
|
||||
|
||||
return `${baseClasses} ${errorClasses} ${focusClasses}`.trim();
|
||||
},
|
||||
option: (
|
||||
state: OptionProps<any, boolean, GroupBase<any>>,
|
||||
): string => {
|
||||
const baseClasses: string =
|
||||
"rounded-lg px-3 py-2 text-sm transition-all duration-150 ease-out flex flex-col gap-1";
|
||||
if (state.isDisabled) {
|
||||
return "bg-gray-100";
|
||||
return `${baseClasses} cursor-not-allowed bg-slate-50 text-slate-400`;
|
||||
}
|
||||
if (state.isSelected) {
|
||||
return "!bg-indigo-500";
|
||||
return `${baseClasses} !bg-indigo-100 !text-indigo-700 shadow-sm ring-1 ring-indigo-200/70`;
|
||||
}
|
||||
if (state.isFocused) {
|
||||
return "!bg-indigo-100";
|
||||
return `${baseClasses} !bg-indigo-50/80 !text-indigo-700 shadow-sm ring-1 ring-indigo-100`;
|
||||
}
|
||||
return "";
|
||||
return `${baseClasses} text-slate-700 hover:bg-slate-50 hover:text-slate-900`;
|
||||
},
|
||||
menu: (): string => {
|
||||
return "!mt-2 !rounded-2xl !border !border-slate-100 !bg-white !shadow-2xl overflow-hidden";
|
||||
},
|
||||
menuList: (): string => {
|
||||
return "!p-2 flex flex-col gap-1";
|
||||
},
|
||||
valueContainer: (): string => {
|
||||
return "!px-3 !py-2 gap-2";
|
||||
},
|
||||
placeholder: (): string => {
|
||||
return "!text-sm !text-slate-400";
|
||||
},
|
||||
singleValue: (): string => {
|
||||
return "!text-sm !font-semibold !text-slate-900 flex flex-col";
|
||||
},
|
||||
multiValue: (): string => {
|
||||
return "!rounded-full !border !border-transparent !bg-slate-100/80 !px-1.5 !py-0.5 items-center gap-1 transition-colors duration-150 hover:!bg-slate-100 hover:!border-slate-200/70";
|
||||
},
|
||||
multiValueLabel: (): string => {
|
||||
return "!text-[11px] !font-medium !text-slate-600 flex items-center gap-1";
|
||||
},
|
||||
multiValueRemove: (): string => {
|
||||
return "!text-slate-400 hover:!bg-red-50 hover:!text-red-500/80 rounded-full transition-colors";
|
||||
},
|
||||
dropdownIndicator: (): string => {
|
||||
return "!px-3 text-slate-400 transition-all duration-150";
|
||||
},
|
||||
input: (): string => {
|
||||
return "!text-sm !text-slate-900";
|
||||
},
|
||||
noOptionsMessage: (): string => {
|
||||
return "!py-2 !text-sm !text-slate-500";
|
||||
},
|
||||
}}
|
||||
components={{
|
||||
IndicatorSeparator: () => null,
|
||||
DropdownIndicator: (
|
||||
indicatorProps: DropdownIndicatorProps<DropdownOption, boolean>,
|
||||
): ReactElement => {
|
||||
return (
|
||||
<selectComponents.DropdownIndicator {...indicatorProps}>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={`h-4 w-4 transition-transform duration-200 ease-out ${
|
||||
indicatorProps.selectProps.menuIsOpen ? "rotate-180" : ""
|
||||
}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M6 9l6 6 6-6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</selectComponents.DropdownIndicator>
|
||||
);
|
||||
},
|
||||
}}
|
||||
isClearable={true}
|
||||
@@ -178,19 +424,19 @@ const Dropdown: FunctionComponent<ComponentProps> = (
|
||||
onChange={(option: any | null) => {
|
||||
if (option) {
|
||||
if (props.isMultiSelect) {
|
||||
const value: Array<DropdownOption> =
|
||||
const selectedOptions: Array<DropdownOption> =
|
||||
option as Array<DropdownOption>;
|
||||
setValue(value);
|
||||
setValue(selectedOptions);
|
||||
|
||||
props.onChange?.(
|
||||
value.map((i: DropdownOption) => {
|
||||
return i.value;
|
||||
selectedOptions.map((item: DropdownOption) => {
|
||||
return item.value;
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
const value: DropdownOption = option as DropdownOption;
|
||||
setValue(value);
|
||||
props.onChange?.(value.value);
|
||||
const selectedOption: DropdownOption = option as DropdownOption;
|
||||
setValue(selectedOption);
|
||||
props.onChange?.(selectedOption.value);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,10 @@ import {
|
||||
CheckboxCategory,
|
||||
} from "../CategoryCheckbox/CategoryCheckboxTypes";
|
||||
import Loader, { LoaderType } from "../Loader/Loader";
|
||||
import {
|
||||
DropdownOption,
|
||||
DropdownOptionLabel,
|
||||
} from "../Dropdown/Dropdown";
|
||||
import Pill, { PillSize } from "../Pill/Pill";
|
||||
import { FormErrors, FormProps, FormSummaryConfig } from "./BasicForm";
|
||||
import BasicModelForm from "./BasicModelForm";
|
||||
@@ -407,22 +411,22 @@ const ModelForm: <TBaseModel extends BaseModel>(
|
||||
for (const field of fields) {
|
||||
if (field.dropdownModal && field.dropdownModal.type) {
|
||||
const tempModel: BaseModel = new field.dropdownModal.type();
|
||||
const select: any = {
|
||||
const select: JSONObject = {
|
||||
[field.dropdownModal.labelField]: true,
|
||||
[field.dropdownModal.valueField]: true,
|
||||
} as any;
|
||||
} as JSONObject;
|
||||
|
||||
let hasAccessControlColumn: boolean = false;
|
||||
|
||||
const labelsColumn: string | null = tempModel.getAccessControlColumn();
|
||||
|
||||
// also select labels, so they can select resources by labels. This is useful for resources like monitors, etc.
|
||||
if (tempModel.getAccessControlColumn()) {
|
||||
select[tempModel.getAccessControlColumn()!] = {
|
||||
|
||||
if (labelsColumn) {
|
||||
select[labelsColumn] = {
|
||||
_id: true,
|
||||
name: true,
|
||||
color: true,
|
||||
} as any;
|
||||
|
||||
hasAccessControlColumn = true;
|
||||
} as JSONObject;
|
||||
}
|
||||
|
||||
const listResult: ListResult<BaseModel> =
|
||||
@@ -431,7 +435,7 @@ const ModelForm: <TBaseModel extends BaseModel>(
|
||||
query: {},
|
||||
limit: LIMIT_PER_PROJECT,
|
||||
skip: 0,
|
||||
select: select,
|
||||
select: select as any,
|
||||
sort: {},
|
||||
});
|
||||
|
||||
@@ -441,14 +445,88 @@ const ModelForm: <TBaseModel extends BaseModel>(
|
||||
throw new BadDataException("Dropdown Modal value mot found");
|
||||
}
|
||||
|
||||
return {
|
||||
label: (item as any)[
|
||||
field.dropdownModal?.labelField
|
||||
].toString(),
|
||||
value: (item as any)[
|
||||
field.dropdownModal?.valueField
|
||||
].toString(),
|
||||
const optionLabels: Array<DropdownOptionLabel> = [];
|
||||
|
||||
const labelColumnsToRead: Array<string> = [];
|
||||
|
||||
if (labelsColumn) {
|
||||
labelColumnsToRead.push(labelsColumn);
|
||||
}
|
||||
|
||||
if (
|
||||
hasAccessControlColumn &&
|
||||
accessControlColumn &&
|
||||
!labelColumnsToRead.includes(accessControlColumn)
|
||||
) {
|
||||
labelColumnsToRead.push(accessControlColumn);
|
||||
}
|
||||
|
||||
for (const columnName of labelColumnsToRead) {
|
||||
const columnValue: Array<GenericObject> =
|
||||
((item as any)[columnName] as Array<GenericObject>) || [];
|
||||
|
||||
for (const label of columnValue) {
|
||||
if (!label) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const rawName: unknown =
|
||||
(label as any).name ??
|
||||
(label as any).title ??
|
||||
(label as any).slug ??
|
||||
null;
|
||||
|
||||
const labelName: string = rawName
|
||||
? rawName.toString().trim()
|
||||
: "";
|
||||
|
||||
if (!labelName) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const labelId: string =
|
||||
(label as any)._id?.toString() ||
|
||||
(label as any).id?.toString() ||
|
||||
(label as any).slug?.toString() ||
|
||||
labelName;
|
||||
|
||||
const exists: boolean = optionLabels.some(
|
||||
(existing: DropdownOptionLabel) => {
|
||||
return (
|
||||
existing.id === labelId && existing.name === labelName
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (!exists) {
|
||||
optionLabels.push({
|
||||
id: labelId,
|
||||
name: labelName,
|
||||
color: (label as any).color || null,
|
||||
slug: (label as any).slug?.toString() || null,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const dropdownLabelValue: string =
|
||||
(item as any)[field.dropdownModal.labelField]?.toString() ||
|
||||
"";
|
||||
|
||||
const dropdownValueValue: string =
|
||||
(item as any)[field.dropdownModal.valueField]?.toString() ||
|
||||
"";
|
||||
|
||||
const dropdownOption: DropdownOption = {
|
||||
label: dropdownLabelValue,
|
||||
value: dropdownValueValue,
|
||||
};
|
||||
|
||||
if (optionLabels.length > 0) {
|
||||
dropdownOption.labels = optionLabels;
|
||||
}
|
||||
|
||||
return dropdownOption;
|
||||
});
|
||||
|
||||
if (hasAccessControlColumn) {
|
||||
|
||||
@@ -3,12 +3,14 @@ import Color from "../../../Types/Color";
|
||||
import React, { CSSProperties, FunctionComponent, ReactElement } from "react";
|
||||
import Tooltip from "../Tooltip/Tooltip";
|
||||
import { GetReactElementFunction } from "../../Types/FunctionTypes";
|
||||
import Icon, { IconType, SizeProp, ThickProp } from "../Icon/Icon";
|
||||
import IconProp from "../../../Types/Icon/IconProp";
|
||||
|
||||
export enum PillSize {
|
||||
Small = "10px",
|
||||
Normal = "13px",
|
||||
Large = "15px",
|
||||
ExtraLarge = "18px",
|
||||
Small = "9px",
|
||||
Normal = "12px",
|
||||
Large = "14px",
|
||||
ExtraLarge = "16px",
|
||||
}
|
||||
|
||||
export interface ComponentProps {
|
||||
@@ -18,6 +20,7 @@ export interface ComponentProps {
|
||||
style?: CSSProperties;
|
||||
isMinimal?: boolean | undefined;
|
||||
tooltip?: string | undefined;
|
||||
icon?: IconProp | IconType | undefined;
|
||||
}
|
||||
|
||||
const Pill: FunctionComponent<ComponentProps> = (
|
||||
@@ -43,28 +46,183 @@ const Pill: FunctionComponent<ComponentProps> = (
|
||||
);
|
||||
}
|
||||
|
||||
const resolvedSize: PillSize = props.size ? props.size : PillSize.Normal;
|
||||
|
||||
const spacingBySize: Record<PillSize, { px: string; py: string; gap: string }> = {
|
||||
[PillSize.Small]: { px: "0.5rem", py: "0.22rem", gap: "0.28rem" },
|
||||
[PillSize.Normal]: { px: "0.65rem", py: "0.35rem", gap: "0.3rem" },
|
||||
[PillSize.Large]: { px: "0.85rem", py: "0.45rem", gap: "0.4rem" },
|
||||
[PillSize.ExtraLarge]: { px: "0.95rem", py: "0.55rem", gap: "0.45rem" },
|
||||
};
|
||||
|
||||
const spacing = spacingBySize[resolvedSize];
|
||||
|
||||
const iconLookups: Record<IconType, IconProp> = {
|
||||
[IconType.Danger]: IconProp.Error,
|
||||
[IconType.Success]: IconProp.CheckCircle,
|
||||
[IconType.Info]: IconProp.Info,
|
||||
[IconType.Warning]: IconProp.Alert,
|
||||
};
|
||||
|
||||
const isIconTypeValue = (value: unknown): value is IconType => {
|
||||
if (!value || typeof value !== "string") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (Object.values(IconType) as Array<string>).includes(value);
|
||||
};
|
||||
|
||||
// Softly shifts a hex color towards white (positive) or black (negative) for hover/focus accents.
|
||||
const adjustColor = (color: string, intensity: number): string => {
|
||||
try {
|
||||
const source: Color = Color.fromString(color);
|
||||
const rgb = Color.colorToRgb(source);
|
||||
|
||||
const delta = Math.abs(intensity);
|
||||
const adjustChannel = (channel: number, lighten: boolean): number => {
|
||||
const boundary = lighten ? 255 : 0;
|
||||
const offset = boundary - channel;
|
||||
|
||||
return Math.round(channel + offset * delta) as number;
|
||||
};
|
||||
|
||||
const shouldLighten = intensity >= 0;
|
||||
const updated = {
|
||||
red: adjustChannel(rgb.red, shouldLighten),
|
||||
green: adjustChannel(rgb.green, shouldLighten),
|
||||
blue: adjustChannel(rgb.blue, shouldLighten),
|
||||
};
|
||||
|
||||
return Color.rgbToColor(updated).toString();
|
||||
} catch (error) {
|
||||
return color;
|
||||
}
|
||||
};
|
||||
|
||||
const baseColor: string = props.style?.backgroundColor
|
||||
? `${props.style.backgroundColor}`
|
||||
: props.color
|
||||
? props.color.toString()
|
||||
: Black.toString();
|
||||
|
||||
const hexColorRegex: RegExp = /^#(?:[0-9a-fA-F]{3}){1,2}$/;
|
||||
const isHexColor: boolean = hexColorRegex.test(baseColor);
|
||||
|
||||
const prefersDarkText: boolean = (() => {
|
||||
try {
|
||||
return Color.shouldUseDarkText(Color.fromString(baseColor));
|
||||
} catch (error) {
|
||||
return true;
|
||||
}
|
||||
})();
|
||||
|
||||
const contrastText: string = props.style?.color
|
||||
? `${props.style.color}`
|
||||
: prefersDarkText
|
||||
? "#0f172a"
|
||||
: "#f8fafc";
|
||||
|
||||
const hoverColor: string = isHexColor
|
||||
? adjustColor(baseColor, prefersDarkText ? -0.12 : 0.14)
|
||||
: baseColor;
|
||||
const focusRingColor: string = isHexColor
|
||||
? adjustColor(baseColor, prefersDarkText ? -0.25 : 0.25)
|
||||
: prefersDarkText
|
||||
? "rgba(15, 23, 42, 0.25)"
|
||||
: "rgba(255, 255, 255, 0.35)";
|
||||
const borderColor: string = prefersDarkText
|
||||
? "rgba(15, 23, 42, 0.12)"
|
||||
: "rgba(255, 255, 255, 0.24)";
|
||||
|
||||
const style: CSSProperties = {
|
||||
fontSize: resolvedSize.toString(),
|
||||
...props.style,
|
||||
};
|
||||
|
||||
style.backgroundColor = baseColor;
|
||||
style.color = contrastText;
|
||||
(style as CSSProperties & Record<string, string>)["--pill-px"] = spacing.px;
|
||||
(style as CSSProperties & Record<string, string>)["--pill-py"] = spacing.py;
|
||||
(style as CSSProperties & Record<string, string>)["--pill-gap"] = spacing.gap;
|
||||
|
||||
(style as CSSProperties & Record<string, string>)["--pill-bg"] = baseColor;
|
||||
(style as CSSProperties & Record<string, string>)["--pill-hover-bg"] = hoverColor;
|
||||
(style as CSSProperties & Record<string, string>)["--pill-text"] = contrastText;
|
||||
(style as CSSProperties & Record<string, string>)["--pill-ring"] = focusRingColor;
|
||||
(style as CSSProperties & Record<string, string>)["--pill-border"] = borderColor;
|
||||
|
||||
const iconGlyph: IconProp | null = (() => {
|
||||
if (!props.icon) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isIconTypeValue(props.icon)) {
|
||||
return iconLookups[props.icon];
|
||||
}
|
||||
|
||||
return props.icon as IconProp;
|
||||
})();
|
||||
|
||||
const iconSizeClass: string = (() => {
|
||||
if (resolvedSize === PillSize.ExtraLarge) {
|
||||
return "h-5 w-5";
|
||||
}
|
||||
|
||||
if (resolvedSize === PillSize.Large) {
|
||||
return "h-4 w-4";
|
||||
}
|
||||
|
||||
if (resolvedSize === PillSize.Small) {
|
||||
return "h-3 w-3";
|
||||
}
|
||||
|
||||
return "h-3.5 w-3.5";
|
||||
})();
|
||||
|
||||
const iconComponentSize: SizeProp = (() => {
|
||||
if (resolvedSize === PillSize.ExtraLarge) {
|
||||
return SizeProp.Large;
|
||||
}
|
||||
|
||||
if (resolvedSize === PillSize.Large) {
|
||||
return SizeProp.Small;
|
||||
}
|
||||
|
||||
if (resolvedSize === PillSize.Small) {
|
||||
return SizeProp.Smaller;
|
||||
}
|
||||
|
||||
return SizeProp.Small;
|
||||
})();
|
||||
|
||||
const iconClassNames: string = isIconTypeValue(props.icon)
|
||||
? iconSizeClass
|
||||
: `text-[color:var(--pill-text)] ${iconSizeClass}`;
|
||||
|
||||
const getPillElement: GetReactElementFunction = (): ReactElement => {
|
||||
const iconElement: ReactElement | null = iconGlyph
|
||||
? (
|
||||
<span className="flex items-center justify-center">
|
||||
<Icon
|
||||
icon={iconGlyph}
|
||||
type={isIconTypeValue(props.icon) ? props.icon : undefined}
|
||||
size={iconComponentSize}
|
||||
thick={ThickProp.Thick}
|
||||
className={iconClassNames}
|
||||
data-testid="pill-icon"
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<span
|
||||
data-testid="pill"
|
||||
className="rounded-full p-1 pl-3 pr-3"
|
||||
style={{
|
||||
// https://stackoverflow.com/questions/3942878/how-to-decide-font-color-in-white-or-black-depending-on-background-color
|
||||
|
||||
color:
|
||||
props.style?.color || Color.shouldUseDarkText(props.color || Black)
|
||||
? "#000000"
|
||||
: "#ffffff",
|
||||
backgroundColor:
|
||||
props.style?.backgroundColor || props.color
|
||||
? props.color.toString()
|
||||
: Black.toString(),
|
||||
fontSize: props.size ? props.size.toString() : PillSize.Normal,
|
||||
...props.style,
|
||||
}}
|
||||
className="inline-flex items-center gap-[var(--pill-gap)] rounded-full border border-[color:var(--pill-border)] bg-[color:var(--pill-bg)] px-[var(--pill-px)] py-[var(--pill-py)] font-semibold leading-none tracking-tight text-[color:var(--pill-text)] shadow-sm transition-colors duration-200 ease-out hover:bg-[color:var(--pill-hover-bg)] hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--pill-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-950 active:shadow-sm"
|
||||
style={style}
|
||||
>
|
||||
{" "}
|
||||
{props.text}{" "}
|
||||
{iconElement}
|
||||
{props.text}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Black } from "Common/Types/BrandColors";
|
||||
import Pill from "Common/UI/Components/Pill/Pill";
|
||||
import Label from "Common/Models/DatabaseModels/Label";
|
||||
import React, { FunctionComponent, ReactElement } from "react";
|
||||
import IconProp from "Common/Types/Icon/IconProp";
|
||||
|
||||
export interface ComponentProps {
|
||||
label: Label;
|
||||
@@ -12,6 +13,7 @@ const LabelElement: FunctionComponent<ComponentProps> = (
|
||||
): ReactElement => {
|
||||
return (
|
||||
<Pill
|
||||
icon={IconProp.Label}
|
||||
color={props.label.color || Black}
|
||||
text={props.label.name || ""}
|
||||
style={{
|
||||
|
||||
Reference in New Issue
Block a user