@flamingo-stack/openframe-frontend-core 0.0.501 → 0.0.502-snapshot.20260731145308

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/dist/{chunk-PDTCSZBY.cjs → chunk-2OXJ6NHI.cjs} +5 -5
  2. package/dist/{chunk-PDTCSZBY.cjs.map → chunk-2OXJ6NHI.cjs.map} +1 -1
  3. package/dist/{chunk-2CEFVI7Q.cjs → chunk-3T4SMJM5.cjs} +73 -73
  4. package/dist/{chunk-2CEFVI7Q.cjs.map → chunk-3T4SMJM5.cjs.map} +1 -1
  5. package/dist/{chunk-AQAZTEI2.js → chunk-4SR2QHMB.js} +2 -2
  6. package/dist/{chunk-I64ZGPA6.cjs → chunk-4UNQMYP7.cjs} +3 -3
  7. package/dist/{chunk-I64ZGPA6.cjs.map → chunk-4UNQMYP7.cjs.map} +1 -1
  8. package/dist/{chunk-LNQUMZTP.cjs → chunk-5RIHLXEP.cjs} +29 -29
  9. package/dist/{chunk-LNQUMZTP.cjs.map → chunk-5RIHLXEP.cjs.map} +1 -1
  10. package/dist/{chunk-2GI3BERF.cjs → chunk-72DF3QTA.cjs} +12 -12
  11. package/dist/{chunk-2GI3BERF.cjs.map → chunk-72DF3QTA.cjs.map} +1 -1
  12. package/dist/{chunk-UWNL733B.cjs → chunk-7L2RVINX.cjs} +32 -32
  13. package/dist/{chunk-UWNL733B.cjs.map → chunk-7L2RVINX.cjs.map} +1 -1
  14. package/dist/{chunk-2GCFMDJ7.cjs → chunk-AWDHRIL3.cjs} +73 -73
  15. package/dist/{chunk-2GCFMDJ7.cjs.map → chunk-AWDHRIL3.cjs.map} +1 -1
  16. package/dist/{chunk-G5LTKJ54.js → chunk-BZF63JXP.js} +5 -5
  17. package/dist/{chunk-O2XKSMIK.js → chunk-CLQEBWV7.js} +3 -3
  18. package/dist/{chunk-NBKOPZQA.cjs → chunk-DMXB3VE2.cjs} +610 -575
  19. package/dist/chunk-DMXB3VE2.cjs.map +1 -0
  20. package/dist/{chunk-P7IOCYGD.js → chunk-F7AD5CUU.js} +2 -2
  21. package/dist/{chunk-C5R2OX7A.cjs → chunk-FFXNRJ5S.cjs} +2 -2
  22. package/dist/{chunk-C5R2OX7A.cjs.map → chunk-FFXNRJ5S.cjs.map} +1 -1
  23. package/dist/{chunk-X26TVHZN.js → chunk-FTOVZ7O2.js} +6 -6
  24. package/dist/{chunk-JCISIQHT.js → chunk-G43CEISB.js} +4 -4
  25. package/dist/{chunk-FRJ2YKAJ.js → chunk-HR6F6IP4.js} +5 -5
  26. package/dist/{chunk-T4J7ZFMP.cjs → chunk-J4T5ZBWG.cjs} +15 -15
  27. package/dist/{chunk-T4J7ZFMP.cjs.map → chunk-J4T5ZBWG.cjs.map} +1 -1
  28. package/dist/{chunk-UNQ3BINL.js → chunk-JGXAVNZQ.js} +3304 -3269
  29. package/dist/chunk-JGXAVNZQ.js.map +1 -0
  30. package/dist/{chunk-WIGZI5TT.cjs → chunk-JIZJDAXI.cjs} +7 -7
  31. package/dist/{chunk-WIGZI5TT.cjs.map → chunk-JIZJDAXI.cjs.map} +1 -1
  32. package/dist/{chunk-7HCAAZQ7.js → chunk-OAWFYZZT.js} +2 -2
  33. package/dist/{chunk-POAM7WDT.cjs → chunk-RR3Z52X5.cjs} +40 -40
  34. package/dist/{chunk-POAM7WDT.cjs.map → chunk-RR3Z52X5.cjs.map} +1 -1
  35. package/dist/{chunk-VPQBQJ4S.js → chunk-SFJRMBJC.js} +2 -2
  36. package/dist/{chunk-EOI74NPQ.cjs → chunk-SGWPW2AO.cjs} +18 -18
  37. package/dist/{chunk-EOI74NPQ.cjs.map → chunk-SGWPW2AO.cjs.map} +1 -1
  38. package/dist/{chunk-RHDMGCIW.cjs → chunk-UCV4LFUG.cjs} +40 -40
  39. package/dist/{chunk-RHDMGCIW.cjs.map → chunk-UCV4LFUG.cjs.map} +1 -1
  40. package/dist/{chunk-6TOLZW2V.js → chunk-VCPTU4IW.js} +6 -6
  41. package/dist/{chunk-QTJXAVZ3.js → chunk-VO2ZONMR.js} +3 -3
  42. package/dist/{chunk-XOF453AB.js → chunk-VYEDTVIZ.js} +3 -3
  43. package/dist/{chunk-BRRHC7GH.js → chunk-XV5SXALY.js} +2 -2
  44. package/dist/components/case-studies/index.cjs +9 -9
  45. package/dist/components/case-studies/index.js +3 -3
  46. package/dist/components/chat/index.cjs +3 -3
  47. package/dist/components/chat/index.js +2 -2
  48. package/dist/components/contact/index.cjs +4 -4
  49. package/dist/components/contact/index.js +3 -3
  50. package/dist/components/content-loading-container.d.ts +1 -1
  51. package/dist/components/docs/index.cjs +6 -6
  52. package/dist/components/docs/index.js +5 -5
  53. package/dist/components/embeds/index.cjs +4 -4
  54. package/dist/components/embeds/index.js +3 -3
  55. package/dist/components/faq/index.cjs +5 -5
  56. package/dist/components/faq/index.js +4 -4
  57. package/dist/components/features/index.cjs +3 -3
  58. package/dist/components/features/index.js +2 -2
  59. package/dist/components/help-center-pages/index.cjs +22 -22
  60. package/dist/components/help-center-pages/index.js +13 -13
  61. package/dist/components/index.cjs +136 -136
  62. package/dist/components/index.js +11 -11
  63. package/dist/components/navigation/index.cjs +3 -3
  64. package/dist/components/navigation/index.js +2 -2
  65. package/dist/components/onboarding-guides/index.cjs +7 -7
  66. package/dist/components/onboarding-guides/index.js +6 -6
  67. package/dist/components/related-content/index.cjs +5 -5
  68. package/dist/components/related-content/index.js +4 -4
  69. package/dist/components/tickets/index.cjs +6 -6
  70. package/dist/components/tickets/index.js +5 -5
  71. package/dist/components/ui/autocomplete.d.ts.map +1 -1
  72. package/dist/components/ui/data-table/data-table-column-filter.d.ts +3 -1
  73. package/dist/components/ui/data-table/data-table-column-filter.d.ts.map +1 -1
  74. package/dist/components/ui/data-table/types.d.ts +12 -1
  75. package/dist/components/ui/data-table/types.d.ts.map +1 -1
  76. package/dist/components/ui/filter-modal.d.ts.map +1 -1
  77. package/dist/components/ui/floating-tooltip.d.ts +9 -4
  78. package/dist/components/ui/floating-tooltip.d.ts.map +1 -1
  79. package/dist/components/ui/index.cjs +3 -3
  80. package/dist/components/ui/index.js +2 -2
  81. package/dist/components/ui/service-card.d.ts.map +1 -1
  82. package/dist/components/ui/status-badge.d.ts +1 -1
  83. package/dist/components/ui/tag.d.ts +1 -1
  84. package/dist/components/ui/tag.d.ts.map +1 -1
  85. package/dist/components/ui/truncate-text.d.ts +14 -1
  86. package/dist/components/ui/truncate-text.d.ts.map +1 -1
  87. package/dist/hooks/index.cjs +2 -2
  88. package/dist/hooks/index.js +1 -1
  89. package/dist/index.cjs +3 -3
  90. package/dist/index.js +2 -2
  91. package/package.json +1 -1
  92. package/src/components/ui/autocomplete.tsx +5 -2
  93. package/src/components/ui/data-table/data-table-column-filter.tsx +33 -24
  94. package/src/components/ui/data-table/data-table-header.tsx +1 -0
  95. package/src/components/ui/data-table/types.ts +12 -1
  96. package/src/components/ui/filter-modal.tsx +4 -1
  97. package/src/components/ui/floating-tooltip.tsx +11 -5
  98. package/src/components/ui/service-card.tsx +18 -3
  99. package/src/components/ui/tag.tsx +34 -1
  100. package/src/components/ui/truncate-text.tsx +20 -0
  101. package/dist/chunk-NBKOPZQA.cjs.map +0 -1
  102. package/dist/chunk-UNQ3BINL.js.map +0 -1
  103. /package/dist/{chunk-AQAZTEI2.js.map → chunk-4SR2QHMB.js.map} +0 -0
  104. /package/dist/{chunk-G5LTKJ54.js.map → chunk-BZF63JXP.js.map} +0 -0
  105. /package/dist/{chunk-O2XKSMIK.js.map → chunk-CLQEBWV7.js.map} +0 -0
  106. /package/dist/{chunk-P7IOCYGD.js.map → chunk-F7AD5CUU.js.map} +0 -0
  107. /package/dist/{chunk-X26TVHZN.js.map → chunk-FTOVZ7O2.js.map} +0 -0
  108. /package/dist/{chunk-JCISIQHT.js.map → chunk-G43CEISB.js.map} +0 -0
  109. /package/dist/{chunk-FRJ2YKAJ.js.map → chunk-HR6F6IP4.js.map} +0 -0
  110. /package/dist/{chunk-7HCAAZQ7.js.map → chunk-OAWFYZZT.js.map} +0 -0
  111. /package/dist/{chunk-VPQBQJ4S.js.map → chunk-SFJRMBJC.js.map} +0 -0
  112. /package/dist/{chunk-6TOLZW2V.js.map → chunk-VCPTU4IW.js.map} +0 -0
  113. /package/dist/{chunk-QTJXAVZ3.js.map → chunk-VO2ZONMR.js.map} +0 -0
  114. /package/dist/{chunk-XOF453AB.js.map → chunk-VYEDTVIZ.js.map} +0 -0
  115. /package/dist/{chunk-BRRHC7GH.js.map → chunk-XV5SXALY.js.map} +0 -0
@@ -19,6 +19,8 @@ export interface DataTableColumnFilterProps {
19
19
  placement?: 'bottom-start' | 'bottom-end' | 'bottom'
20
20
  label: string
21
21
  align?: 'left' | 'center' | 'right'
22
+ /** Options are still loading — see `meta.filter.pending`. */
23
+ pending?: boolean
22
24
  }
23
25
 
24
26
  /** Filter dropdown wrapper that reads/writes TanStack column filter state. */
@@ -28,6 +30,7 @@ export function DataTableColumnFilter({
28
30
  placement = 'bottom-start',
29
31
  label,
30
32
  align = 'left',
33
+ pending = false,
31
34
  }: DataTableColumnFilterProps) {
32
35
  const currentValue = column.getFilterValue() as string[] | undefined
33
36
  const activeCount = currentValue?.length ?? 0
@@ -62,14 +65,17 @@ export function DataTableColumnFilter({
62
65
  column.setFilterValue(undefined)
63
66
  }, [column])
64
67
 
65
- // A filterable column whose choices have not arrived yet. Declaring the filter
66
- // with no options is how a caller says "this column filters, ask me later" —
67
- // the alternative, dropping `meta.filter` until the query resolves, takes the
68
- // header cell with it: below `lg` only filterable cells are shown at all, so
69
- // every header on a tablet vanished on reload and came back when the options
70
- // landed. It also moved the labels, since the funnel is only drawn for a
71
- // filterable column.
72
- const isPending = options.length === 0
68
+ // Nothing to choose from. Two ways to get here, and they look different:
69
+ //
70
+ // - `pending`: the options are in flight. The funnel is drawn but inert, so
71
+ // it does not appear out of nowhere when the query answers — the state a
72
+ // skeleton, or a table whose facets load separately from its rows, asks for.
73
+ // - otherwise: there are no options and there will be none. No funnel: a
74
+ // control that cannot be opened reads as a broken one, and the design shows
75
+ // the bare label. `meta.filter` still being declared is what keeps the
76
+ // header cell alive on tablet (below `lg` only filterable cells show) and
77
+ // keeps its 48px box — only the glyph goes.
78
+ const isEmpty = options.length === 0
73
79
 
74
80
  const trigger = (
75
81
  <div
@@ -82,36 +88,39 @@ export function DataTableColumnFilter({
82
88
  // the row it belongs to.
83
89
  'group flex w-full items-center gap-[var(--spacing-system-xsf)] h-12 rounded-sm transition-colors duration-200 select-none',
84
90
  alignJustify(align),
85
- isPending ? 'cursor-default' : 'cursor-pointer',
91
+ isEmpty ? 'cursor-default' : 'cursor-pointer',
86
92
  )}
87
- aria-label={`Filter by ${label}`}
88
- aria-disabled={isPending || undefined}
93
+ // Only a real control announces itself as one. With no options this box
94
+ // is the plain header label the non-filterable columns beside it render.
95
+ aria-label={isEmpty ? undefined : `Filter by ${label}`}
89
96
  >
90
97
  <span
91
98
  className={cn(
92
99
  'text-h5 text-ods-text-secondary whitespace-nowrap transition-colors duration-200',
93
- !isPending && 'group-hover:text-ods-text-primary',
100
+ !isEmpty && 'group-hover:text-ods-text-primary',
94
101
  )}
95
102
  >
96
103
  {label}
97
104
  </span>
98
- {/* Rendered while pending too, and that is the point: it holds its own
99
- width, so the label does not shift sideways the moment the options
100
- resolve. */}
101
- <Filter02Icon
102
- className={cn(
103
- 'w-4 h-4 transition-colors',
104
- activeCount > 0 && 'text-ods-accent',
105
- activeCount === 0 && 'text-ods-text-secondary',
106
- activeCount === 0 && !isPending && 'group-hover:text-ods-text-primary',
107
- )}
108
- />
105
+ {/* Kept while PENDING — it holds its own width, so the label does not
106
+ shift sideways the moment the options resolve — and dropped once we
107
+ know there are none. */}
108
+ {(!isEmpty || pending) && (
109
+ <Filter02Icon
110
+ className={cn(
111
+ 'w-4 h-4 transition-colors',
112
+ activeCount > 0 && 'text-ods-accent',
113
+ activeCount === 0 && 'text-ods-text-secondary',
114
+ activeCount === 0 && !isEmpty && 'group-hover:text-ods-text-primary',
115
+ )}
116
+ />
117
+ )}
109
118
  </div>
110
119
  )
111
120
 
112
121
  // Same box, no dropdown: opening onto an empty list would be a worse answer
113
122
  // than not opening at all.
114
- if (isPending) {
123
+ if (isEmpty) {
115
124
  return <div className="block w-full">{trigger}</div>
116
125
  }
117
126
 
@@ -240,6 +240,7 @@ function HeaderCell({ header, sort, onSortChange }: HeaderCellProps) {
240
240
  column={column}
241
241
  options={meta!.filter!.options}
242
242
  placement={meta!.filter!.placement}
243
+ pending={meta!.filter!.pending}
243
244
  label={resolveHeaderLabel(header)}
244
245
  align={align}
245
246
  />
@@ -22,10 +22,21 @@ declare module '@tanstack/react-table' {
22
22
  * (e.g. the date filter calendar trigger).
23
23
  */
24
24
  alwaysShowHeader?: boolean
25
- /** If present, header renders a filter dropdown with these options. */
25
+ /**
26
+ * If present, the column filters: the header cell survives on tablet and,
27
+ * when there is something to choose from, renders the funnel + dropdown.
28
+ *
29
+ * An EMPTY `options` list draws no funnel at all — a control that cannot be
30
+ * opened is worse than no control, and every table in the design shows the
31
+ * bare label there. Set `pending` for the other empty case, "the options are
32
+ * still loading": that one keeps the funnel, so it does not appear out of
33
+ * nowhere the moment the query answers.
34
+ */
26
35
  filter?: {
27
36
  options: DataTableFilterOption[]
28
37
  placement?: 'bottom-start' | 'bottom-end' | 'bottom'
38
+ /** Options are in flight — draw the funnel, inert, instead of hiding it. */
39
+ pending?: boolean
29
40
  }
30
41
  /**
31
42
  * Opt-in: header renders the sort indicator and clicks toggle column sort.
@@ -236,8 +236,11 @@ export function FilterModal({
236
236
  </div>
237
237
  )}
238
238
 
239
- {/* Filter groups */}
239
+ {/* Filter groups. A group with no options is skipped, not drawn as an
240
+ empty box: same rule the column funnels follow — a control that
241
+ cannot be used is worse than no control. */}
240
242
  {filterGroups.map((group) => {
243
+ if (group.options.length === 0) return null
241
244
  const groupSelection = selectedFilters[group.id] || []
242
245
  return (
243
246
  <div key={group.id} className="flex flex-col gap-1">
@@ -27,12 +27,17 @@ interface FloatingTooltipProps {
27
27
  /** Disable the tooltip without unmounting the trigger wrapper. */
28
28
  disabled?: boolean
29
29
  /**
30
- * Classes for the trigger wrapper — the `div` this component puts around
31
- * `children` to anchor the tooltip. That div becomes the flex/grid item in the
32
- * caller's layout, so anything the child needed there (`min-w-0`, `flex-1`)
30
+ * Classes for the trigger wrapper — the element this component puts around
31
+ * `children` to anchor the tooltip. That element becomes the flex/grid item in
32
+ * the caller's layout, so anything the child needed there (`min-w-0`, `flex-1`)
33
33
  * must move onto it. Omit it and the wrapper carries no class, exactly as before.
34
34
  */
35
35
  triggerClassName?: string
36
+ /**
37
+ * Trigger wrapper element. Default `'div'`. Use `'span'` where a block element
38
+ * is invalid HTML — inside a `<p>`, a heading, or another `<span>`.
39
+ */
40
+ as?: 'div' | 'span'
36
41
  }
37
42
 
38
43
  // Parse colored text markup like [YELLOW]text[/YELLOW] into JSX
@@ -93,6 +98,7 @@ export function FloatingTooltip({
93
98
  delayDuration = 0,
94
99
  disabled = false,
95
100
  triggerClassName,
101
+ as: TriggerTag = 'div',
96
102
  }: FloatingTooltipProps) {
97
103
  const [isOpen, setIsOpen] = React.useState(false)
98
104
  const arrowRef = React.useRef<HTMLDivElement>(null)
@@ -159,9 +165,9 @@ export function FloatingTooltip({
159
165
 
160
166
  return (
161
167
  <>
162
- <div ref={refs.setReference} className={triggerClassName} {...getReferenceProps()}>
168
+ <TriggerTag ref={refs.setReference} className={triggerClassName} {...getReferenceProps()}>
163
169
  {children}
164
- </div>
170
+ </TriggerTag>
165
171
  <FloatingPortal>
166
172
  {isOpen && (
167
173
  <div
@@ -8,6 +8,9 @@ import { CheckIcon } from '../icons-v2-generated/signs-and-symbols'
8
8
  import { ExternalLink } from 'lucide-react'
9
9
  import { OpenFrameLogo } from '../..'
10
10
  import { useCopyToClipboard } from '../../hooks/use-copy-to-clipboard'
11
+ import { useIsTruncated } from '../../hooks/ui/use-is-truncated'
12
+ import { FloatingTooltip } from './floating-tooltip'
13
+ import { TruncateText } from './truncate-text'
11
14
 
12
15
  export type ServiceCardRowAction = {
13
16
  copy?: boolean
@@ -61,9 +64,11 @@ export function ServiceCard({ title, subtitle, icon, tag, rows, className }: Ser
61
64
  {resolvedIcon}
62
65
  </div>
63
66
  <div className="min-w-0">
64
- <div className="text-h3 text-ods-text-primary truncate" title={title}>{title}</div>
67
+ <TruncateText variant="h3">{title}</TruncateText>
65
68
  {subtitle && (
66
- <div className="text-h6 text-ods-text-secondary truncate" title={subtitle}>{subtitle}</div>
69
+ <TruncateText variant="h6" tone="secondary">
70
+ {subtitle}
71
+ </TruncateText>
67
72
  )}
68
73
  </div>
69
74
  </div>
@@ -87,6 +92,7 @@ export function ServiceCard({ title, subtitle, icon, tag, rows, className }: Ser
87
92
  function ServiceCardRowItem({ row }: { row: ServiceCardRow }) {
88
93
  const [revealed, setRevealed] = useState(false)
89
94
  const { copy, copied } = useCopyToClipboard()
95
+ const { ref: valueRef, isTruncated: valueTruncated } = useIsTruncated<HTMLDivElement>(row.value)
90
96
  const actions = useMemo<ServiceCardRowAction>(() => ({ copy: true, open: !!row.href, reveal: !!row.isSecret, ...row.actions }), [row])
91
97
 
92
98
  const displayValue = row.isSecret ? <MaskedValue value={row.value} isRevealed={revealed} /> : <span>{row.value}</span>
@@ -104,7 +110,16 @@ function ServiceCardRowItem({ row }: { row: ServiceCardRow }) {
104
110
  <div className="w-20 md:w-24 shrink-0 text-h6 text-ods-text-primary">{row.label}</div>
105
111
  )}
106
112
  <div className={cn('flex-1 h-12 rounded-md border border-ods-border bg-ods-bg px-3 md:px-4 flex items-center justify-between min-w-0', row.monospace ? 'font-mono' : '')}>
107
- <div className="truncate text-ods-text-primary min-w-0" title={typeof row.value === 'string' ? row.value : undefined}>{displayValue}</div>
113
+ {/* No tooltip while a secret is masked — the old native `title` leaked the raw value on hover. */}
114
+ <FloatingTooltip
115
+ content={row.value}
116
+ side="top"
117
+ disabled={!valueTruncated || (row.isSecret && !revealed)}
118
+ triggerClassName="min-w-0"
119
+ className="max-w-xs [overflow-wrap:anywhere]"
120
+ >
121
+ <div ref={valueRef} className="truncate text-ods-text-primary">{displayValue}</div>
122
+ </FloatingTooltip>
108
123
  <div className="flex items-center gap-2 pl-3 flex-shrink-0">
109
124
  {actions.reveal && (
110
125
  <button
@@ -4,6 +4,8 @@ import React from "react"
4
4
  import { cva, type VariantProps } from "class-variance-authority"
5
5
  import { XmarkCircleIcon } from "../icons-v2-generated/signs-and-symbols/xmark-circle-icon"
6
6
  import { cn } from "../../utils/cn"
7
+ import { useIsTruncated } from "../../hooks/ui/use-is-truncated"
8
+ import { FloatingTooltip } from "./floating-tooltip"
7
9
 
8
10
  const tagVariants = cva(
9
11
  [
@@ -107,6 +109,37 @@ export interface TagProps
107
109
  as?: 'div' | 'span'
108
110
  }
109
111
 
112
+ /**
113
+ * The tag's label slot. String labels get a `FloatingTooltip` with the full
114
+ * value when (and only when) the text is actually clipped — replacing the old
115
+ * native `title`, which showed with the OS delay, unstyled, and even when
116
+ * nothing was truncated. Node labels render exactly as before: the caller owns
117
+ * their content (and any tooltip), so none is added here.
118
+ * The trigger is a `span` so the tag stays valid phrasing content (`as="span"`
119
+ * exists precisely for tags inside a `<p>`).
120
+ */
121
+ function TagLabel({ label, labelClassName }: { label: React.ReactNode; labelClassName?: string }) {
122
+ const isString = typeof label === 'string'
123
+ const { ref, isTruncated } = useIsTruncated<HTMLSpanElement>(isString ? label : null)
124
+
125
+ if (!isString) {
126
+ return <span className={cn("truncate", labelClassName)}>{label}</span>
127
+ }
128
+
129
+ return (
130
+ <FloatingTooltip
131
+ content={label}
132
+ side="top"
133
+ disabled={!isTruncated}
134
+ as="span"
135
+ triggerClassName="min-w-0 max-w-full"
136
+ className="max-w-xs whitespace-pre-line [overflow-wrap:anywhere]"
137
+ >
138
+ <span ref={ref} className={cn("truncate block", labelClassName)}>{label}</span>
139
+ </FloatingTooltip>
140
+ )
141
+ }
142
+
110
143
  function Tag({
111
144
  label,
112
145
  variant,
@@ -134,7 +167,7 @@ function Tag({
134
167
  {icon}
135
168
  </span>
136
169
  )}
137
- <span className={cn("truncate", labelClassName)} title={typeof label === 'string' ? label : undefined}>{label}</span>
170
+ <TagLabel label={label} labelClassName={labelClassName} />
138
171
  {onClose && (
139
172
  <button
140
173
  type="button"
@@ -26,6 +26,19 @@ export interface TruncateTextProps {
26
26
  tone?: TruncateTextTone
27
27
  /** Force the monospace (heading) font family — preserves the variant's size while swapping family. */
28
28
  mono?: boolean
29
+ /**
30
+ * Extra classes for the tooltip trigger wrapper — the element that becomes the
31
+ * flex/grid item in the caller's layout (e.g. `flex-1`). Merged after the
32
+ * built-in `min-w-0 max-w-full`, which is what lets the trigger shrink inside
33
+ * flex rows so the text ellipsizes instead of clipping.
34
+ */
35
+ triggerClassName?: string
36
+ /**
37
+ * Trigger wrapper element. Default `'div'`. Use `'span'` where a block element
38
+ * is invalid HTML — inside a `<p>`, a heading, or another `<span>`; the span
39
+ * trigger is `inline-block` so the inner truncation still measures.
40
+ */
41
+ as?: 'div' | 'span'
29
42
  }
30
43
 
31
44
  const VARIANT_CLASS: Record<TruncateTextVariant, string> = {
@@ -69,6 +82,8 @@ export function TruncateText({
69
82
  variant = 'h4',
70
83
  tone = 'primary',
71
84
  mono = false,
85
+ triggerClassName,
86
+ as = 'div',
72
87
  }: TruncateTextProps) {
73
88
  const isMultiLine = lines > 1
74
89
  const { ref, isTruncated } = useIsTruncated<HTMLSpanElement>(children, { multiline: isMultiLine })
@@ -83,6 +98,11 @@ export function TruncateText({
83
98
  side={side}
84
99
  disabled={!isTruncated}
85
100
  className="max-w-xs whitespace-pre-line [overflow-wrap:anywhere]"
101
+ as={as}
102
+ // min-w-0: a flex/grid item's automatic minimum size is content-based when
103
+ // overflow is visible, so without this the trigger refuses to shrink and the
104
+ // text clips with no ellipsis and no tooltip.
105
+ triggerClassName={cn('min-w-0 max-w-full', as === 'span' && 'inline-block align-bottom', triggerClassName)}
86
106
  >
87
107
  <span
88
108
  ref={ref}