@lovett/ui 0.0.11 → 0.2.0

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 (142) hide show
  1. package/dist/chunk-RBYWGBQ2.js +2752 -0
  2. package/dist/chunk-RBYWGBQ2.js.map +1 -0
  3. package/dist/index.d.ts +5574 -57
  4. package/dist/index.js +21650 -11206
  5. package/dist/index.js.map +1 -1
  6. package/dist/rich-composer-impl-5NO443A6.js +1859 -0
  7. package/dist/rich-composer-impl-5NO443A6.js.map +1 -0
  8. package/dist/styles.css +1570 -0
  9. package/dist/tokens.css +112 -0
  10. package/package.json +8 -1
  11. package/src/__tests__/avatar.test.tsx +272 -0
  12. package/src/__tests__/bar-chart.test.tsx +809 -0
  13. package/src/__tests__/board.test.tsx +420 -0
  14. package/src/__tests__/chart-math.test.ts +922 -0
  15. package/src/__tests__/chart-series.test.ts +339 -0
  16. package/src/__tests__/code-block.test.tsx +134 -0
  17. package/src/__tests__/display-popover.test.tsx +195 -0
  18. package/src/__tests__/display-store.test.tsx +307 -0
  19. package/src/__tests__/donut-chart.test.tsx +397 -0
  20. package/src/__tests__/dropdown-menu.test.tsx +156 -2
  21. package/src/__tests__/filter-menu.test.tsx +175 -0
  22. package/src/__tests__/gauge-ring.test.tsx +233 -0
  23. package/src/__tests__/line-chart.test.tsx +612 -0
  24. package/src/__tests__/ranked-bars.test.tsx +343 -0
  25. package/src/__tests__/remark-underline.test.ts +194 -0
  26. package/src/__tests__/sortable.test.tsx +394 -0
  27. package/src/__tests__/sparkline.test.tsx +368 -0
  28. package/src/__tests__/stat-layer.test.tsx +271 -0
  29. package/src/__tests__/stat-strip.test.tsx +175 -0
  30. package/src/__tests__/status.test.tsx +293 -0
  31. package/src/__tests__/tabs.test.tsx +303 -0
  32. package/src/__tests__/token-shape.test.ts +132 -2
  33. package/src/avatar.tsx +352 -0
  34. package/src/bar-chart.tsx +1214 -0
  35. package/src/board.tsx +658 -0
  36. package/src/chart-frame.tsx +960 -0
  37. package/src/chart-legend.tsx +304 -0
  38. package/src/chart-tooltip.tsx +267 -0
  39. package/src/code-block.tsx +62 -8
  40. package/src/delta-chip.tsx +263 -0
  41. package/src/detail/__tests__/activity-pane.test.tsx +369 -0
  42. package/src/detail/__tests__/detail-chrome.test.tsx +134 -0
  43. package/src/detail/__tests__/detail-surface.test.tsx +529 -0
  44. package/src/detail/__tests__/field-row.test.tsx +357 -0
  45. package/src/detail/activity-pane.tsx +611 -0
  46. package/src/detail/calendar.tsx +355 -0
  47. package/src/detail/detail-divider.tsx +261 -0
  48. package/src/detail/detail-header.tsx +287 -0
  49. package/src/detail/detail-menu.tsx +254 -0
  50. package/src/detail/detail-surface.tsx +1110 -0
  51. package/src/detail/field-list.tsx +196 -0
  52. package/src/detail/field-row.tsx +1131 -0
  53. package/src/detail/index.ts +58 -0
  54. package/src/detail/segmented-choice.tsx +94 -0
  55. package/src/detail/types.ts +129 -0
  56. package/src/display-popover.tsx +487 -0
  57. package/src/display-store.tsx +301 -0
  58. package/src/donut-chart.tsx +988 -0
  59. package/src/dropdown-menu.tsx +290 -19
  60. package/src/filter-core/EXPORTS.md +85 -0
  61. package/src/filter-core/__tests__/columns.test.ts +159 -0
  62. package/src/filter-core/__tests__/faceting.test.ts +193 -0
  63. package/src/filter-core/__tests__/filter-fns.test.ts +519 -0
  64. package/src/filter-core/__tests__/operators.test.ts +235 -0
  65. package/src/filter-core/__tests__/state.test.ts +268 -0
  66. package/src/filter-core/__tests__/url.test.ts +350 -0
  67. package/src/filter-core/columns.ts +134 -0
  68. package/src/filter-core/date-utils.ts +38 -0
  69. package/src/filter-core/examples/task-filter-columns.ts +121 -0
  70. package/src/filter-core/faceting.ts +120 -0
  71. package/src/filter-core/filter-fns.ts +335 -0
  72. package/src/filter-core/index.ts +105 -0
  73. package/src/filter-core/operators.ts +433 -0
  74. package/src/filter-core/state.ts +280 -0
  75. package/src/filter-core/types.ts +247 -0
  76. package/src/filter-core/url.ts +261 -0
  77. package/src/filter-dropdown.tsx +12 -0
  78. package/src/filter-menu.tsx +649 -0
  79. package/src/floating-drawer.tsx +19 -1
  80. package/src/gauge-ring.tsx +435 -0
  81. package/src/hue.ts +52 -0
  82. package/src/index.ts +303 -0
  83. package/src/kbd.tsx +27 -4
  84. package/src/lib/chart.ts +866 -0
  85. package/src/lib/focus.ts +43 -1
  86. package/src/lib/layer-stack.ts +30 -3
  87. package/src/lib/remark-underline.ts +443 -0
  88. package/src/lib/series.ts +169 -0
  89. package/src/line-chart.tsx +1176 -0
  90. package/src/markdown.tsx +26 -7
  91. package/src/modal.tsx +42 -18
  92. package/src/progress-ledger.tsx +304 -0
  93. package/src/ranked-bars.tsx +386 -0
  94. package/src/segmented-pill.tsx +32 -9
  95. package/src/sortable.tsx +520 -1
  96. package/src/sparkline.tsx +416 -0
  97. package/src/stat-card.tsx +376 -0
  98. package/src/stat-strip.tsx +327 -0
  99. package/src/status.tsx +215 -0
  100. package/src/styles.css +1570 -0
  101. package/src/tabs.tsx +206 -25
  102. package/src/task-card.tsx +610 -0
  103. package/src/thread/__tests__/comment-body-hostile.test.tsx +331 -0
  104. package/src/thread/__tests__/comment-tree.test.ts +151 -0
  105. package/src/thread/__tests__/emoji.test.ts +187 -0
  106. package/src/thread/__tests__/fixtures/thread-fixture.ts +252 -0
  107. package/src/thread/__tests__/link-preview-source.test.ts +120 -0
  108. package/src/thread/__tests__/link-preview.test.tsx +600 -0
  109. package/src/thread/__tests__/markdown-format.test.ts +82 -0
  110. package/src/thread/__tests__/markdown-spec.test.ts +469 -0
  111. package/src/thread/__tests__/relative-time.test.ts +71 -0
  112. package/src/thread/__tests__/rich-composer.test.tsx +799 -0
  113. package/src/thread/__tests__/scroll-caret.test.ts +58 -0
  114. package/src/thread/__tests__/suggestion-list.test.tsx +648 -0
  115. package/src/thread/__tests__/thread-scroll-ownership.test.tsx +88 -0
  116. package/src/thread/__tests__/thread.test.tsx +742 -0
  117. package/src/thread/__tests__/use-attachments.test.tsx +679 -0
  118. package/src/thread/actions.tsx +196 -0
  119. package/src/thread/attachments.tsx +1071 -0
  120. package/src/thread/comment-body.tsx +148 -0
  121. package/src/thread/comment-tree.ts +182 -0
  122. package/src/thread/comment.tsx +967 -0
  123. package/src/thread/composer-footer.tsx +125 -0
  124. package/src/thread/composer.tsx +319 -0
  125. package/src/thread/emoji.ts +283 -0
  126. package/src/thread/index.ts +153 -0
  127. package/src/thread/link-preview.tsx +341 -0
  128. package/src/thread/markdown-format.ts +155 -0
  129. package/src/thread/markdown-spec.ts +754 -0
  130. package/src/thread/rail.tsx +372 -0
  131. package/src/thread/reactions.tsx +415 -0
  132. package/src/thread/relative-time.tsx +94 -0
  133. package/src/thread/rich-composer-impl.tsx +1601 -0
  134. package/src/thread/rich-composer.tsx +195 -0
  135. package/src/thread/scroll-caret.ts +37 -0
  136. package/src/thread/suggestion-list.tsx +182 -0
  137. package/src/thread/thread.tsx +718 -0
  138. package/src/thread/types.ts +232 -0
  139. package/src/thread/use-attachments.ts +598 -0
  140. package/src/thread/use-now.ts +73 -0
  141. package/src/thread/use-thread.ts +316 -0
  142. package/src/tokens.css +112 -0
package/src/avatar.tsx ADDED
@@ -0,0 +1,352 @@
1
+ /**
2
+ * Avatar / AvatarGroup — a person.
3
+ *
4
+ * ADR-147 D14. Photo when there is one, initials on an identity tint when
5
+ * there is not.
6
+ *
7
+ * ## Why this exists
8
+ *
9
+ * Four forks of "a circle with someone's initials in it" shipped independently
10
+ * and disagreed on every axis (ADR-147 Context):
11
+ *
12
+ * • `task-card.tsx` `AvatarStack` — whole-string hash mod 7, first+last
13
+ * initial, opaque tint-ladder fill. *(lives on `feat/tasks-lens`)*
14
+ * • `components/ui/community/primitives.tsx` — first-CHARACTER hash mod 4
15
+ * over `--info/--success/--warning/--accent`, translucent `@0.14` fill.
16
+ * • `components/ui/admin/primitives.tsx` — byte-identical clone of it.
17
+ * • `lenses/assets/folders/folder-row.tsx` — no hash at all: every owner
18
+ * rendered in `--accent`.
19
+ *
20
+ * So the same person was a different colour on three screens, one screen gave
21
+ * everybody the same colour, and two of the four spent the view's single
22
+ * accent moment on a face.
23
+ *
24
+ * ## The API is `@lovett/ds`'s, for four measured reasons (D14)
25
+ *
26
+ * 1. **`tint` is a prop** — which colour belongs to which person is DATA (it
27
+ * travels with the user record: `users.avatar_color`, D6), not theme. The
28
+ * hash is the documented FALLBACK for until that column is populated, and
29
+ * it lives in exactly one function (`avatarTintForKey`) rather than four.
30
+ * 2. **The initial stays in the DOM behind the photo**, so a broken `src`
31
+ * degrades to a legible letter rather than a coloured hole. No fork even
32
+ * accepted a `src`.
33
+ * 3. **`--avatar-ring`** takes the HOST SURFACE's colour from a CSS variable,
34
+ * so a stack on a card and a stack in an inset well both read correctly
35
+ * with no variant prop. Set it with one of the `.ds-avatar-on-*` classes.
36
+ * 4. **`max` and `total` are separate**, so three faces render out of forty
37
+ * without passing forty children.
38
+ *
39
+ * ## Two deliberate divergences from ds
40
+ *
41
+ * • **`tint` is a palette KEY, not a colour string.** ds passes a raw colour
42
+ * and puts it straight in `style.background`. Here that would walk an
43
+ * arbitrary literal out of the database into app code, which is the exact
44
+ * thing CLAUDE.md §4's token boundary exists to stop — and a raw colour
45
+ * arrives with no ink or edge partner, so nothing can promise it is
46
+ * readable. A key resolves to a full contrast-tuned rung set in both
47
+ * themes. Same shape as `FolderCard`'s `colorKey` (ADR-035 D6).
48
+ * • **The initials are not white.** ds hardcodes a white literal because its
49
+ * tint is a saturated data colour. Ours is an 18% mix toward the card, so
50
+ * the glyph takes the ink rung — see the measured note in `styles.css`.
51
+ *
52
+ * ## D5 — nothing here fabricates a name
53
+ *
54
+ * `users.name`, `given_name` and `family_name` are ALL nullable by standing
55
+ * policy (`0014_sso_names.sql:13-16`: store NULL rather than invent). So
56
+ * `name` is typed `string | null`: still required (the caller must say what it
57
+ * has), still allowed to be nothing. A missing name renders `?` on a stable
58
+ * tint and is announced as "Unknown person" — honest, not invented, and it
59
+ * does not crash.
60
+ */
61
+
62
+ import { Children, useState } from 'react'
63
+ import type { CSSProperties, HTMLAttributes, ReactNode } from 'react'
64
+ import { FOLDER_COLOR_KEYS, type FolderColorKey } from './folder-card'
65
+ import { cn } from './lib/utils'
66
+
67
+ /* ── TINT ─────────────────────────────────────────────────────────────────── */
68
+
69
+ /**
70
+ * The identity hues an avatar may take: every `--folder-*` key except `red`.
71
+ *
72
+ * `red` is excluded on purpose — `--folder-red` is byte-identical to
73
+ * `--accent` in light mode, so a red face would spend the view's single accent
74
+ * moment on a person (CLAUDE.md §Design principles → Color & emphasis).
75
+ *
76
+ * Deliberately the same set, in the same order, as the tasks board's
77
+ * `BOARD_HUE_KEYS` (`feat/tasks-lens`, `hue.ts`), so that when that branch
78
+ * merges its `AvatarStack` can adopt this primitive without any avatar
79
+ * changing colour.
80
+ */
81
+ export type AvatarTint = Exclude<FolderColorKey, 'red'>
82
+
83
+ /** All avatar tints, in palette order. */
84
+ export const AVATAR_TINTS: readonly AvatarTint[] = FOLDER_COLOR_KEYS.filter(
85
+ (key): key is AvatarTint => key !== 'red',
86
+ )
87
+
88
+ /**
89
+ * Parse a stored colour into a tint, at the boundary.
90
+ *
91
+ * `users.avatar_color` (ADR-147 D6) is a nullable TEXT column: a string from
92
+ * the database, not a domain value. This is the one place it is validated —
93
+ * anything that is not a known key (an old value, a hand-edited row, a hex
94
+ * somebody wrote by hand) becomes `undefined`, and `Avatar` then falls back to
95
+ * the hash rather than rendering an unknown class or an untrusted literal.
96
+ *
97
+ * Returns `undefined` rather than a default so the caller can spread it
98
+ * conditionally under `exactOptionalPropertyTypes`, and so "no colour stored"
99
+ * and "slate was stored" stay distinguishable.
100
+ */
101
+ export function asAvatarTint(value: string | null | undefined): AvatarTint | undefined {
102
+ if (value == null) return undefined
103
+ return AVATAR_TINTS.find((tint) => tint === value)
104
+ }
105
+
106
+ /**
107
+ * A stable tint for an arbitrary identity string.
108
+ *
109
+ * **This is the fallback, not the plan.** ADR-147 D6 adds `users.avatar_color`
110
+ * so identity colour becomes data that travels with the user record; until it
111
+ * is populated, this derives one. Deterministic, so the same person is the
112
+ * same colour on every render and every machine — an avatar that changes
113
+ * colour reads as a different person. A display hash, not a distribution or
114
+ * security primitive.
115
+ *
116
+ * Same algorithm and same key order as `hueForKey` on `feat/tasks-lens`; when
117
+ * that branch merges, the two collapse into one function.
118
+ */
119
+ export function avatarTintForKey(seed: string): AvatarTint {
120
+ let hash = 0
121
+ for (let i = 0; i < seed.length; i += 1) {
122
+ hash = (hash * 31 + seed.charCodeAt(i)) | 0
123
+ }
124
+ const index = Math.abs(hash) % AVATAR_TINTS.length
125
+ return AVATAR_TINTS[index] ?? 'slate'
126
+ }
127
+
128
+ /* ── INITIALS ─────────────────────────────────────────────────────────────── */
129
+
130
+ /** First whole glyph, so a surrogate pair or a combining mark is not sliced. */
131
+ function firstGlyph(word: string): string {
132
+ return [...word][0] ?? ''
133
+ }
134
+
135
+ /**
136
+ * The monogram for a display name — **first and last word, capped at two**.
137
+ *
138
+ * The one helper, replacing three that disagreed: `task-card` took first+last,
139
+ * community and admin took the first two words ("Mary Jane Watson" → `MW` here
140
+ * and `MJ` there), and `folder-row` took a server-supplied string with no cap
141
+ * at all.
142
+ *
143
+ * First+last wins because the family name is the more identifying letter, and
144
+ * because it matches the tasks board, which is the only fork whose colour and
145
+ * geometry are already correct.
146
+ *
147
+ * Returns `?` for a null, empty or whitespace-only name (D5). It never
148
+ * fabricates.
149
+ */
150
+ export function initials(name: string | null | undefined): string {
151
+ const words = (name ?? '').trim().split(/\s+/).filter(Boolean)
152
+ if (words.length === 0) return '?'
153
+ const first = firstGlyph(words[0] ?? '')
154
+ const last = words.length > 1 ? firstGlyph(words[words.length - 1] ?? '') : ''
155
+ return (first + last).toUpperCase()
156
+ }
157
+
158
+ /** Announced when a display name is null or blank. Honest, never invented. */
159
+ const UNKNOWN_PERSON = 'Unknown person'
160
+
161
+ /* ── AVATAR ───────────────────────────────────────────────────────────────── */
162
+
163
+ export type AvatarSize = 'sm' | 'md' | 'lg'
164
+
165
+ /**
166
+ * The two custom properties that carry a caller-supplied diameter into the
167
+ * CSS. Declared rather than cast: `as` on a style object is exactly the escape
168
+ * hatch CLAUDE.md asks us not to reach for.
169
+ */
170
+ interface AvatarSizeVars extends CSSProperties {
171
+ '--avatar-size'?: string
172
+ '--avatar-font'?: string
173
+ }
174
+
175
+ /**
176
+ * Coefficient for a TWO-character monogram, taken from the community fork
177
+ * (`Math.round(size * 0.36)`), so its nine existing call sites keep the type
178
+ * size they render today. The three named sizes carry their own measured
179
+ * values in `styles.css` instead of deriving from this.
180
+ */
181
+ function sizeVars(px: number): AvatarSizeVars {
182
+ return { '--avatar-size': `${px}px`, '--avatar-font': `${Math.round(px * 0.36)}px` }
183
+ }
184
+
185
+ export interface AvatarProps extends Omit<HTMLAttributes<HTMLSpanElement>, 'color'> {
186
+ /**
187
+ * Display name. Drives the monogram and the accessible name.
188
+ *
189
+ * Nullable on purpose (D5) — every name column in `users` is nullable by
190
+ * policy, and a component that cannot be handed a null forces every caller
191
+ * to invent a string.
192
+ */
193
+ name: string | null
194
+ /**
195
+ * Identity tint. Pass `users.avatar_color` once D6's column is populated;
196
+ * omit it and a stable tint is derived from `seed ?? name`.
197
+ */
198
+ tint?: AvatarTint
199
+ /** Photo. Falls back to the initials if it fails to load, or is absent. */
200
+ src?: string | null
201
+ /**
202
+ * `sm` (22) · `md` (32) · `lg` (52), or a raw pixel diameter.
203
+ *
204
+ * The number exists because the community and admin surfaces render avatars
205
+ * at 20, 24, 26 and 34 — none of which is on any scale. It keeps those call
206
+ * sites pixel-identical through the consolidation instead of making a
207
+ * colour refactor also a layout one. New code should use a named size.
208
+ */
209
+ size?: AvatarSize | number
210
+ /** Override the derived monogram — e.g. `"You"`, or a server-supplied one. */
211
+ initial?: string
212
+ /**
213
+ * Separating ring in the HOST SURFACE's colour, for overlapping faces.
214
+ * Reads `--avatar-ring`, which `.ds-avatar-on-card` / `-frame` / `-inset`
215
+ * set; defaults to the card surface.
216
+ */
217
+ ring?: boolean
218
+ /**
219
+ * Hash seed when `tint` is absent. Prefer a stable user id — a name can
220
+ * change, and the colour should not change with it.
221
+ */
222
+ seed?: string
223
+ /**
224
+ * Hide from the accessibility tree. Pass it when the name is already
225
+ * rendered beside the avatar (a table row, a byline), so a screen reader
226
+ * does not announce the same person twice.
227
+ */
228
+ decorative?: boolean
229
+ }
230
+
231
+ export function Avatar({
232
+ name,
233
+ tint,
234
+ src,
235
+ size = 'sm',
236
+ initial,
237
+ ring,
238
+ seed,
239
+ decorative,
240
+ className,
241
+ style,
242
+ ...rest
243
+ }: AvatarProps) {
244
+ // Keyed by the src that failed rather than a bare boolean, so swapping in a
245
+ // new photo re-tries it. A boolean latches: once one URL 404s, every later
246
+ // one is suppressed for the life of the component.
247
+ const [failedSrc, setFailedSrc] = useState<string | null>(null)
248
+
249
+ const trimmed = name?.trim() ?? ''
250
+ const label = trimmed === '' ? UNKNOWN_PERSON : trimmed
251
+ const glyph = initial ?? initials(name)
252
+ const hue = tint ?? avatarTintForKey(seed ?? label)
253
+ const showImage = src != null && src !== '' && failedSrc !== src
254
+ const numeric = typeof size === 'number'
255
+
256
+ return (
257
+ <span
258
+ {...(decorative
259
+ ? { 'aria-hidden': true }
260
+ : { role: 'img', 'aria-label': label, title: label })}
261
+ className={cn(
262
+ 'ds-avatar',
263
+ `ds-avatar--${hue}`,
264
+ !numeric && size !== 'sm' && `ds-avatar--${size}`,
265
+ ring && 'ds-avatar--ring',
266
+ className,
267
+ )}
268
+ style={numeric ? { ...sizeVars(size), ...style } : style}
269
+ {...rest}
270
+ >
271
+ {/*
272
+ The initial stays in the DOM BEHIND the photo, so a broken `src`
273
+ degrades to something legible instead of a coloured hole.
274
+
275
+ IT NEEDS ITS OWN ELEMENT. As a bare text node it is an anonymous grid
276
+ item; `text-box-trim` is not inherited and a grid container has no line
277
+ boxes of its own, so the optical-centring rule on `.ds-avatar` reaches
278
+ nothing at all. Measured in @lovett/ds: the glyph sat 1.3px high in a
279
+ 24px avatar. A span is the element the trim can actually apply to.
280
+ */}
281
+ <span className="ds-avatar__mono">{glyph}</span>
282
+ {showImage ? (
283
+ <img className="ds-avatar__img" src={src} alt="" onError={() => setFailedSrc(src)} />
284
+ ) : null}
285
+ </span>
286
+ )
287
+ }
288
+
289
+ /* ── AVATAR GROUP ─────────────────────────────────────────────────────────── */
290
+
291
+ export interface AvatarGroupProps extends HTMLAttributes<HTMLSpanElement> {
292
+ /** Show at most this many faces; the rest collapse into a `+N`. */
293
+ max?: number
294
+ /**
295
+ * Total participants, when more exist than were passed as children — so a
296
+ * thread with forty people renders three faces and `+37` without the caller
297
+ * mounting forty avatars to throw thirty-seven away.
298
+ */
299
+ total?: number
300
+ /** Size of the `+N` chip. Match the faces. */
301
+ size?: AvatarSize | number
302
+ /**
303
+ * Faces overlap and take a separating ring (default). `false` lays them
304
+ * side by side with a gap — what the tasks board card does, where the row
305
+ * is already tight and an overlap reads as a stack of one blob.
306
+ */
307
+ overlap?: boolean
308
+ children: ReactNode
309
+ }
310
+
311
+ export function AvatarGroup({
312
+ max = 3,
313
+ total,
314
+ size = 'sm',
315
+ overlap = true,
316
+ className,
317
+ children,
318
+ ...rest
319
+ }: AvatarGroupProps) {
320
+ const faces = Children.toArray(children)
321
+ const shown = faces.slice(0, max)
322
+ const hidden = (total ?? faces.length) - shown.length
323
+ const numeric = typeof size === 'number'
324
+
325
+ return (
326
+ <span
327
+ className={cn(
328
+ 'ds-avatar-group',
329
+ overlap ? 'ds-avatar-group--overlap' : 'ds-avatar-group--spaced',
330
+ className,
331
+ )}
332
+ {...rest}
333
+ >
334
+ {shown}
335
+ {hidden > 0 ? (
336
+ <span
337
+ role="img"
338
+ aria-label={`${hidden} more`}
339
+ className={cn(
340
+ 'ds-avatar-group__more',
341
+ !numeric && size !== 'sm' && `ds-avatar--${size}`,
342
+ )}
343
+ style={numeric ? sizeVars(size) : undefined}
344
+ >
345
+ {/* tabular-nums lives in the CSS: +9 becoming +10 must not shift the
346
+ faces to its left. */}
347
+ +{hidden}
348
+ </span>
349
+ ) : null}
350
+ </span>
351
+ )
352
+ }