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}}
+
+)