diff --git a/app/components/AttachFloatingIpModal.tsx b/app/components/AttachFloatingIpModal.tsx index ca4f2a91a..a6563145a 100644 --- a/app/components/AttachFloatingIpModal.tsx +++ b/app/components/AttachFloatingIpModal.tsx @@ -20,6 +20,7 @@ import { import { ListboxField } from '~/components/form/fields/ListboxField' import { HL } from '~/components/HL' import { addToast } from '~/stores/toast' +import { ItemLabel } from '~/ui/lib/ItemLabel' import { Message } from '~/ui/lib/Message' import { Slash } from '~/ui/lib/Slash' @@ -50,21 +51,16 @@ function IpPoolName({ ipPoolId }: { ipPoolId: string }) { function FloatingIpLabel({ fip }: { fip: FloatingIp }) { return ( -
-
{fip.name}
-
-
{fip.ip}
- - {fip.description && ( - <> - -
- {fip.description} -
- - )} -
-
+ + {fip.ip} + + {fip.description && ( + <> + + {fip.description} + + )} + ) } diff --git a/app/components/PoolListboxItem.tsx b/app/components/PoolListboxItem.tsx index c3041d61c..6b781b3fd 100644 --- a/app/components/PoolListboxItem.tsx +++ b/app/components/PoolListboxItem.tsx @@ -10,6 +10,7 @@ import type { IpVersion } from '@oxide/api' import { Badge } from '@oxide/design-system/ui' import { IpVersionBadge } from '~/components/IpVersionBadge' +import { ItemLabel } from '~/ui/lib/ItemLabel' import type { ListboxItem } from '~/ui/lib/Listbox' /** Common fields of SiloIpPool and SiloSubnetPool used for display */ @@ -25,16 +26,17 @@ export function toPoolItem(p: PoolLike): ListboxItem { const value = p.name const selectedLabel = p.name const label = ( -
-
- {p.name} - {p.isDefault && default} - -
- {!!p.description && ( -
{p.description}
- )} -
+ + {p.name} + {p.isDefault && default} + + + } + > + {p.description} + ) return { value, selectedLabel, label } } diff --git a/app/components/form/fields/ImageSelectField.tsx b/app/components/form/fields/ImageSelectField.tsx index 13bb17663..6d23427c1 100644 --- a/app/components/form/fields/ImageSelectField.tsx +++ b/app/components/form/fields/ImageSelectField.tsx @@ -11,6 +11,7 @@ import type { Image } from '@oxide/api' import type { InstanceCreateInput } from '~/forms/instance-create' import type { ComboboxItem } from '~/ui/lib/Combobox' +import { ItemLabel } from '~/ui/lib/ItemLabel' import { Slash } from '~/ui/lib/Slash' import { diskSizeNearest10 } from '~/util/math' import { bytesToGiB, GiB } from '~/util/units' @@ -79,11 +80,6 @@ export function toImageComboboxItem( return { value: id, selectedLabel: name, - label: ( -
-
{name}
-
{itemMetadata}
-
- ), + label: {itemMetadata}, } } diff --git a/app/forms/disk-create.tsx b/app/forms/disk-create.tsx index 2e8f182d4..32c097d1e 100644 --- a/app/forms/disk-create.tsx +++ b/app/forms/disk-create.tsx @@ -34,6 +34,7 @@ import { HL } from '~/components/HL' import { useProjectSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { FieldLabel } from '~/ui/lib/FieldLabel' +import { ItemLabel } from '~/ui/lib/ItemLabel' import { SideModalFormDocs } from '~/ui/lib/ModalLinks' import { Radio } from '~/ui/lib/Radio' import { RadioGroup } from '~/ui/lib/RadioGroup' @@ -420,14 +421,11 @@ const SnapshotSelectField = ({ control }: { control: Control }) value: i.id, selectedLabel: i.name, label: ( - <> -
{i.name}
-
- Created on {toLocaleDateString(i.timeCreated)} - {formattedSize.value}{' '} - {formattedSize.unit} -
- + + Created on {toLocaleDateString(i.timeCreated)} + {formattedSize.value}{' '} + {formattedSize.unit} + ), } })} diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index 2895873ea..ce57bbea6 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -70,6 +70,7 @@ import { Button } from '~/ui/lib/Button' import { toComboboxItems } from '~/ui/lib/Combobox' import { FormDivider } from '~/ui/lib/Divider' import { EmptyMessage } from '~/ui/lib/EmptyMessage' +import { ItemLabel } from '~/ui/lib/ItemLabel' import { Listbox } from '~/ui/lib/Listbox' import { Message } from '~/ui/lib/Message' import { MiniTable } from '~/ui/lib/MiniTable' @@ -862,20 +863,15 @@ export default function CreateInstanceForm() { } const FloatingIpLabel = ({ ip }: { ip: FloatingIp }) => ( -
-
{ip.name}
-
-
{ip.ip}
- {ip.description && ( - <> - -
- {ip.description} -
- - )} -
-
+ + {ip.ip} + {ip.description && ( + <> + + {ip.description} + + )} + ) const NetworkingSection = ({ diff --git a/app/pages/system/networking/IpPoolPage.tsx b/app/pages/system/networking/IpPoolPage.tsx index bfb544ea5..180314657 100644 --- a/app/pages/system/networking/IpPoolPage.tsx +++ b/app/pages/system/networking/IpPoolPage.tsx @@ -53,6 +53,7 @@ import { type ComboboxItem } from '~/ui/lib/Combobox' import { CreateButton, CreateLink } from '~/ui/lib/CreateButton' import * as Dropdown from '~/ui/lib/DropdownMenu' import { EmptyMessage } from '~/ui/lib/EmptyMessage' +import { ItemLabel } from '~/ui/lib/ItemLabel' import { Message } from '~/ui/lib/Message' import { Modal } from '~/ui/lib/Modal' import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' @@ -476,16 +477,7 @@ const defaultValues: LinkSiloFormValues = { silo: undefined, isDefault: false } const toSiloComboboxItem = ({ name, description }: Silo): ComboboxItem => ({ value: name, selectedLabel: name, - label: ( -
-
{name}
- {description && ( -
- {description} -
- )} -
- ), + label: {description}, }) function LinkSiloModal({ onDismiss }: { onDismiss: () => void }) { diff --git a/app/ui/lib/ItemLabel.tsx b/app/ui/lib/ItemLabel.tsx new file mode 100644 index 000000000..49df14c53 --- /dev/null +++ b/app/ui/lib/ItemLabel.tsx @@ -0,0 +1,40 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import type { ReactNode } from 'react' + +import { classed } from '~/util/classed' + +/** + * Secondary line in a combobox or listbox item label, e.g., a description or + * metadata shown under the item name. The `selected:` variant keeps the text + * legible against the accent background of the highlighted option. User-supplied + * descriptions can be arbitrarily long, so clamp at two lines to keep option + * heights bounded, and break long unbroken tokens (like URLs) so they wrap + * instead of overflowing. Content must be inline (no flex/block children) or + * the line clamp won't apply. + */ +const ItemDescription = classed.div`text-secondary selected:text-accent-secondary line-clamp-2 break-words` + +type ItemLabelProps = { + /** Resource name, plus any badges (the name row is a flex row with gap) */ + name: ReactNode + /** Secondary line: a description, slash-separated metadata, or both */ + children?: ReactNode +} + +/** + * Standard two-line label for combobox and listbox items: resource name over + * an optional secondary line. + */ +export const ItemLabel = ({ name, children }: ItemLabelProps) => ( +
+
{name}
+ {children && {children}} +
+)