@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/tabs.tsx CHANGED
@@ -9,11 +9,99 @@
9
9
  * section scroll-spy + section-nav-as-tabs on profile pages. <Tabs>
10
10
  * is the simpler horizontal-button-row for top-of-content tab navigation.
11
11
  *
12
+ * ## Three variants, and when the third one is the right answer
13
+ *
14
+ * `underline` (default) and `pill` are top-of-content navigation: the strip
15
+ * sits above a page's worth of content and its width is whatever the label
16
+ * run comes to. `segmented` is for a tab strip that has to hold a COLUMN —
17
+ * a boxed track with equal-width segments that fills its container, so it
18
+ * reads as one control rather than a run of words drifting left. It is the
19
+ * same box as `SegmentedChoice` (inset track, opaque raised active segment,
20
+ * accent border, `--shadow-sm`, concentric `--radius-md` outer /
21
+ * `--radius-sm` inner) and the same 36px overall height, so a segmented
22
+ * strip in one pane sits flush with a search + filter row in the next.
23
+ *
24
+ * `SegmentedChoice` is NOT interchangeable with it: that primitive is
25
+ * `role="group"` + `aria-pressed`, which is correct for a filter and wrong
26
+ * for a real tablist. When the segments switch panels, they are tabs, and
27
+ * they belong here where the APG keyboard model already lives.
28
+ *
29
+ * The active segment is an opaque step UP onto `--surface-card`, per
30
+ * CLAUDE.md §2 — never an alpha tint on a translucent track, which
31
+ * composites to a raised element darker than the well it sits in.
32
+ *
12
33
  * Token discipline: all colors via public tokens. Active tab uses the
13
34
  * accent token; inactive uses text-tertiary.
35
+ *
36
+ * ## Keyboard (WAI-ARIA APG "Tabs")
37
+ *
38
+ * `role="tablist"` is a promise about keyboard behaviour, and until this it
39
+ * was not kept: every tab was a separate tab stop, and the arrow keys did
40
+ * nothing. The pattern is implemented here in full —
41
+ *
42
+ * - **Roving tabindex.** Exactly one tab is in the document's tab ring
43
+ * (`tabIndex={0}`); the rest are `-1`. Tab enters the tablist once and
44
+ * leaves it once, which is the whole point of the role. Disabled tabs are
45
+ * never the roving one, and if the active tab is disabled (or is not in
46
+ * `tabs` at all) the first enabled tab holds the stop rather than nothing
47
+ * holding it.
48
+ * - **ArrowLeft / ArrowRight move and WRAP**; **Home / End** jump to the
49
+ * first / last enabled tab.
50
+ * - **AUTOMATIC activation** — moving selects. Chosen deliberately: every
51
+ * consumer in this workspace switches a panel that is already rendered
52
+ * client-side, so selection is free and manual activation (move with
53
+ * arrows, commit with Enter/Space) would just be a second keystroke for
54
+ * no benefit. Manual activation is for tabs whose panels cost a fetch.
55
+ *
56
+ * LTR only. Nothing in this file reads `dir` or uses logical properties, so
57
+ * ArrowRight means "next" — mirroring it here would be half of an RTL story
58
+ * the rest of the component does not tell.
59
+ *
60
+ * ## Panels, ids, and the `aria-controls` promise
61
+ *
62
+ * `<Tabs>` renders the tab STRIP and nothing else — every consumer in the
63
+ * workspace renders its own panel. So `aria-controls` is opt-in via
64
+ * `Tab.panelId`, and is emitted only when the consumer says which element it
65
+ * points at. Emitting a generated `aria-controls` that resolves to nothing is
66
+ * a broken promise to assistive tech, not a partial credit (it is exactly the
67
+ * defect `SegmentedPill` was carrying).
68
+ *
69
+ * The other direction is exposed rather than generated: each tab's `id` is
70
+ * `<idPrefix>-tab-<tab.id>`, and `idPrefix` is a prop, so a consumer can
71
+ * point its panel's `aria-labelledby` at a tab it knows the id of. The panel
72
+ * itself is the consumer's to render, with `role="tabpanel"`,
73
+ * `aria-labelledby` back at the tab, and `tabIndex={0}` so a keyboard user
74
+ * can reach panel content that holds no focusable of its own.
14
75
  */
15
76
 
16
- import type { ReactNode } from 'react'
77
+ import { useId, useRef, type CSSProperties, type KeyboardEvent, type ReactNode } from 'react'
78
+
79
+ type TabsVariant = 'underline' | 'pill' | 'segmented'
80
+
81
+ /** The track the segments sit in, per variant. */
82
+ const TRACK_CLASS: Readonly<Record<TabsVariant, (stretch: boolean) => string>> = {
83
+ underline: (stretch) =>
84
+ `${stretch ? 'flex w-full' : 'inline-flex'} items-center gap-1 border-b`,
85
+ pill: (stretch) => `${stretch ? 'flex w-full' : 'inline-flex'} items-center gap-1 p-1`,
86
+ segmented: (stretch) =>
87
+ `${stretch ? 'flex w-full' : 'inline-flex'} items-center gap-[var(--space-1)] border p-[var(--space-1)]`,
88
+ }
89
+
90
+ const TRACK_STYLE: Readonly<Record<TabsVariant, CSSProperties>> = {
91
+ underline: { borderColor: 'rgb(var(--border))' },
92
+ pill: {
93
+ background: 'rgb(var(--surface-overlay-soft))',
94
+ border: '1px solid rgb(var(--border))',
95
+ borderRadius: 'var(--radius-full)',
96
+ },
97
+ // Outer --radius-md (12) minus the 4px of track padding leaves --radius-sm
98
+ // (8) for the segment: concentric, per the radius rule.
99
+ segmented: {
100
+ background: 'rgb(var(--surface-inset))',
101
+ borderColor: 'rgb(var(--border))',
102
+ borderRadius: 'var(--radius-md)',
103
+ },
104
+ }
17
105
 
18
106
  export interface Tab {
19
107
  id: string
@@ -26,6 +114,13 @@ export interface Tab {
26
114
  /** Accessible name for ICON-ONLY tabs (label rendered empty) — e.g. the
27
115
  * responsive platform switcher's compact tier (ADR-135). */
28
116
  ariaLabel?: string
117
+ /**
118
+ * `id` of the `role="tabpanel"` element this tab controls, when the
119
+ * consumer renders one. Supplying it emits `aria-controls`; omitting it
120
+ * emits nothing, because an `aria-controls` that resolves to no element is
121
+ * worse than none at all.
122
+ */
123
+ panelId?: string
29
124
  }
30
125
 
31
126
  export interface TabsProps {
@@ -34,10 +129,21 @@ export interface TabsProps {
34
129
  onTabChange: (id: string) => void
35
130
  /** Compact (12px label) or default (13px). */
36
131
  size?: 'sm' | 'md'
37
- /** Underline (default) or pill active-state. */
38
- variant?: 'underline' | 'pill'
132
+ /**
133
+ * `underline` (default) and `pill` are top-of-content nav; `segmented` is
134
+ * the boxed track for a strip that has to fill a column — see the
135
+ * docblock. `segmented` is almost always paired with `stretch`.
136
+ */
137
+ variant?: TabsVariant
39
138
  /** Stretch the tablist to full width with equal-width, centred tabs. */
40
139
  stretch?: boolean
140
+ /**
141
+ * Namespace for the generated tab `id`s, which are
142
+ * `<idPrefix>-tab-<tab.id>`. Pass a stable string when a panel needs to
143
+ * name its tab in `aria-labelledby`; leave it out and a `useId()` prefix is
144
+ * minted, which is unique but not knowable from outside.
145
+ */
146
+ idPrefix?: string
41
147
  className?: string
42
148
  }
43
149
 
@@ -48,34 +154,96 @@ export function Tabs({
48
154
  size = 'md',
49
155
  variant = 'underline',
50
156
  stretch = false,
157
+ idPrefix,
51
158
  className,
52
159
  }: TabsProps) {
53
160
  const textSize = size === 'sm' ? 'text-[12px]' : 'text-[13px]'
161
+
162
+ const reactId = useId()
163
+ // `useId()` returns a colon-wrapped token in React 18 and a underscore-
164
+ // wrapped one in 19; neither is a valid bare CSS selector, and these ids end
165
+ // up in `aria-labelledby` where a consumer may well want to query them.
166
+ const prefix = idPrefix ?? `tabs-${reactId.replace(/[^a-zA-Z0-9_-]/g, '')}`
167
+ const tabDomId = (id: string) => `${prefix}-tab-${id}`
168
+
169
+ // Movement runs over the ENABLED tabs only: a disabled button cannot take
170
+ // focus, so stepping onto one would silently strand the keyboard.
171
+ const enabled = tabs.filter((tab) => tab.disabled !== true)
172
+ const rovingId = enabled.some((tab) => tab.id === activeTabId)
173
+ ? activeTabId
174
+ : enabled[0]?.id
175
+
176
+ const buttonRefs = useRef(new Map<string, HTMLButtonElement>())
177
+
178
+ const activate = (next: Tab | undefined) => {
179
+ if (next === undefined) return
180
+ // Focus first: the button is already mounted, and moving focus before the
181
+ // state change means a consumer that re-renders synchronously cannot land
182
+ // us on `<body>`.
183
+ buttonRefs.current.get(next.id)?.focus()
184
+ if (next.id !== activeTabId) onTabChange(next.id)
185
+ }
186
+
187
+ const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>, tab: Tab) => {
188
+ const index = enabled.findIndex((item) => item.id === tab.id)
189
+ if (index === -1) return
190
+ const count = enabled.length
191
+ switch (event.key) {
192
+ case 'ArrowRight':
193
+ event.preventDefault()
194
+ activate(enabled[(index + 1) % count])
195
+ break
196
+ case 'ArrowLeft':
197
+ event.preventDefault()
198
+ activate(enabled[(index - 1 + count) % count])
199
+ break
200
+ case 'Home':
201
+ event.preventDefault()
202
+ activate(enabled[0])
203
+ break
204
+ case 'End':
205
+ event.preventDefault()
206
+ activate(enabled[count - 1])
207
+ break
208
+ default:
209
+ break
210
+ }
211
+ }
212
+
54
213
  return (
55
214
  <div
56
215
  role="tablist"
57
- className={
58
- className ??
59
- (variant === 'underline'
60
- ? `${stretch ? 'flex w-full' : 'inline-flex'} items-center gap-1 border-b`
61
- : `${stretch ? 'flex w-full' : 'inline-flex'} items-center gap-1 p-1`)
62
- }
63
- style={
64
- variant === 'underline'
65
- ? { borderColor: 'rgb(var(--border))' }
66
- : {
67
- background: 'rgb(var(--surface-overlay-soft))',
68
- border: '1px solid rgb(var(--border))',
69
- borderRadius: 'var(--radius-full)',
70
- }
71
- }
216
+ className={className ?? TRACK_CLASS[variant](stretch)}
217
+ style={TRACK_STYLE[variant]}
72
218
  >
73
219
  {tabs.map((tab) => {
74
220
  const isActive = tab.id === activeTabId
75
- const base = `inline-flex items-center gap-1.5 px-3 h-9 ${textSize} font-medium transition-colors${stretch ? ' flex-1 justify-center' : ''}`
76
- const underlineClass = `${base} border-b-2 -mb-px`
77
- const pillClass = `${base}`
78
- const className = variant === 'underline' ? underlineClass : pillClass
221
+ const grow = stretch ? ' flex-1 justify-center' : ''
222
+ // `segmented` sits in a 4px-padded track, so its segment is 26px —
223
+ // 26 + 4 + 4 + 1 + 1 = the 36px the field-list header already stands
224
+ // at. The other two variants keep their 36px button.
225
+ const base =
226
+ variant === 'segmented'
227
+ ? // `min-w-0` is load-bearing: a flex item's default `min-width:
228
+ // auto` refuses to shrink below its content, so at the activity
229
+ // pane's 280px floor the three segments stopped being equal and
230
+ // the last one ran out through the track's border. With it they
231
+ // stay equal and the label truncates instead.
232
+ `inline-flex min-w-0 items-center gap-1.5 h-[26px] px-[var(--space-2)] rounded-[var(--radius-sm)] border ${textSize} font-semibold transition-[background-color,color,border-color,box-shadow] duration-[var(--dur-fast)] ease-[var(--ease-out)] motion-reduce:transition-none focus-visible:outline-none focus-visible:[box-shadow:var(--ring-focus)]${grow}`
233
+ : `inline-flex items-center gap-1.5 px-3 h-9 ${textSize} font-medium transition-colors${grow}`
234
+ // Segmented carries its colours as CLASSES, not inline style, so the
235
+ // focus ring can beat the active segment's `--shadow-sm`; an inline
236
+ // box-shadow would win over `focus-visible:` unconditionally and the
237
+ // one variant with no underline would be the one with no focus ring.
238
+ const segmentedState = isActive
239
+ ? 'border-[rgb(var(--accent))] bg-[rgb(var(--surface-card))] text-[rgb(var(--accent-ink))] [box-shadow:var(--shadow-sm)]'
240
+ : 'border-transparent text-[rgb(var(--text-tertiary))] hover:text-[rgb(var(--foreground))]'
241
+ const className =
242
+ variant === 'underline'
243
+ ? `${base} border-b-2 -mb-px`
244
+ : variant === 'segmented'
245
+ ? `${base} ${segmentedState}`
246
+ : base
79
247
 
80
248
  const activeStyle =
81
249
  variant === 'underline'
@@ -106,24 +274,37 @@ export function Tabs({
106
274
  return (
107
275
  <button
108
276
  key={tab.id}
277
+ ref={(node) => {
278
+ if (node === null) buttonRefs.current.delete(tab.id)
279
+ else buttonRefs.current.set(tab.id, node)
280
+ }}
281
+ id={tabDomId(tab.id)}
109
282
  role="tab"
110
283
  type="button"
111
284
  aria-selected={isActive}
112
285
  aria-label={tab.ariaLabel}
286
+ {...(tab.panelId === undefined ? {} : { 'aria-controls': tab.panelId })}
287
+ // Roving tabindex — one stop for the whole strip.
288
+ tabIndex={tab.id === rovingId ? 0 : -1}
113
289
  disabled={tab.disabled}
290
+ onKeyDown={(event) => handleKeyDown(event, tab)}
114
291
  onClick={() => onTabChange(tab.id)}
115
292
  className={`${className} ${tab.disabled ? 'cursor-not-allowed opacity-40' : 'cursor-pointer'}`}
116
- style={isActive ? activeStyle : inactiveStyle}
293
+ {...(variant === 'segmented'
294
+ ? {}
295
+ : { style: isActive ? activeStyle : inactiveStyle })}
117
296
  >
118
297
  {tab.icon && (
119
298
  <span className="flex-shrink-0" aria-hidden>
120
299
  {tab.icon}
121
300
  </span>
122
301
  )}
123
- {tab.label}
302
+ <span className={variant === 'segmented' ? 'min-w-0 truncate' : undefined}>
303
+ {tab.label}
304
+ </span>
124
305
  {tab.count !== undefined && (
125
306
  <span
126
- className="ml-0.5 inline-flex min-w-[1.1rem] items-center justify-center rounded-full px-1 text-[10.5px] font-semibold tabular-nums"
307
+ className="ml-0.5 inline-flex min-w-[1.1rem] shrink-0 items-center justify-center rounded-full px-1 text-[10.5px] font-semibold tabular-nums"
127
308
  style={
128
309
  isActive && variant === 'pill'
129
310
  ? {