@linto-ai/transcript-ui-ui 0.9.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 (39) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +17 -0
  3. package/package.json +55 -0
  4. package/src/atoms/Badge.vue +26 -0
  5. package/src/atoms/Button.vue +264 -0
  6. package/src/atoms/CodeBlock.vue +98 -0
  7. package/src/atoms/CopyButton.vue +98 -0
  8. package/src/atoms/EditableText.vue +115 -0
  9. package/src/atoms/EditorCheckbox.vue +66 -0
  10. package/src/atoms/EditorIcon.vue +59 -0
  11. package/src/atoms/MarkdownEditor.vue +542 -0
  12. package/src/atoms/MarkdownView.vue +159 -0
  13. package/src/atoms/PopoverList.vue +92 -0
  14. package/src/atoms/SelectableListItem.vue +183 -0
  15. package/src/atoms/SpeakerIndicator.vue +19 -0
  16. package/src/atoms/SwitchToggle.vue +90 -0
  17. package/src/atoms/UserAvatar.vue +38 -0
  18. package/src/atoms/icons.ts +108 -0
  19. package/src/index.ts +36 -0
  20. package/src/molecules/DocumentArticle.vue +169 -0
  21. package/src/molecules/DownloadMenu.vue +48 -0
  22. package/src/molecules/FormInput.vue +510 -0
  23. package/src/molecules/SpeakerMenu.vue +43 -0
  24. package/src/molecules/Tabs.vue +119 -0
  25. package/src/molecules/TurnTextEditor.vue +93 -0
  26. package/src/styles/base.css +110 -0
  27. package/src/styles/fonts.css +45 -0
  28. package/src/styles/popover-list.css +67 -0
  29. package/src/styles/variables.css +131 -0
  30. package/src/turndown-plugin-gfm.d.ts +10 -0
  31. package/src/utils/computeInitials.ts +10 -0
  32. package/src/utils/computeSelectionOffset.ts +10 -0
  33. package/src/utils/computeTextOffsetInContainer.ts +54 -0
  34. package/src/utils/highlight.ts +46 -0
  35. package/src/utils/markdown.ts +56 -0
  36. package/src/utils/placeCaretAt.ts +23 -0
  37. package/src/utils/shadowAwareSelection.ts +52 -0
  38. package/src/utils/test/computeInitials.test.ts +15 -0
  39. package/src/utils/test/computeTextOffsetInContainer.test.ts +53 -0
@@ -0,0 +1,510 @@
1
+ <script lang="ts">
2
+ export interface FormField {
3
+ // Consumed by FormInput
4
+ label?: string
5
+ type?:
6
+ | "text"
7
+ | "email"
8
+ | "password"
9
+ | "number"
10
+ | "search"
11
+ | "tel"
12
+ | "url"
13
+ | "date"
14
+ placeholder?: string
15
+ autocomplete?: string
16
+ error?: string | null
17
+ value?: string
18
+ /** Additional HTML attributes spread onto the input (v-bind). */
19
+ customParams?: Record<string, unknown>
20
+
21
+ // Consumed by external form logic (validators, state management).
22
+ // Accepted by FormInput for migration compatibility.
23
+ required?: boolean
24
+ valid?: boolean
25
+ loading?: boolean
26
+ name?: string
27
+ id?: string
28
+ testField?: (field: FormField, t: (key: string) => string) => boolean
29
+ disabled?: boolean
30
+ disabledReason?: string
31
+ }
32
+
33
+ export const EMPTY_FIELD: FormField = {
34
+ label: "",
35
+ value: "",
36
+ error: null,
37
+ valid: false,
38
+ loading: false,
39
+ }
40
+ </script>
41
+
42
+ <script setup lang="ts">
43
+ import { computed, ref, useId, useTemplateRef, watch, onMounted } from "vue"
44
+ import Button from "../atoms/Button.vue"
45
+ import { useI18n } from "@linto-ai/transcript-ui-i18n"
46
+
47
+ // ── Props / emits / slots ──────────────────────────────────────────────
48
+
49
+ const props = withDefaults(
50
+ defineProps<{
51
+ field: FormField
52
+ modelValue?: string
53
+
54
+ // Behavior
55
+ disabled?: boolean
56
+ readonly?: boolean
57
+ focus?: boolean
58
+ withConfirmation?: boolean
59
+
60
+ // Layout
61
+ inline?: boolean
62
+ fullWidth?: boolean
63
+ size?: "sm" | "md" | "lg"
64
+
65
+ // Variants
66
+ textarea?: boolean
67
+ code?: boolean
68
+ select?: boolean
69
+ options?: { value: string; label: string }[]
70
+
71
+ // IDs
72
+ inputId?: string
73
+ }>(),
74
+ {
75
+ size: "md",
76
+ },
77
+ )
78
+
79
+ const emit = defineEmits<{
80
+ "update:modelValue": [value: string]
81
+ input: [value: string]
82
+ "on-confirm": []
83
+ "on-cancel": []
84
+ keydown: [event: KeyboardEvent]
85
+ blur: [event: FocusEvent]
86
+ focus: [event: FocusEvent]
87
+ }>()
88
+
89
+ defineSlots<{
90
+ default?: () => unknown
91
+ "custom-input"?: (props: { id: string; disabled: boolean }) => unknown
92
+ "content-after-label"?: () => unknown
93
+ "content-after-input"?: () => unknown
94
+ "content-bottom-input"?: () => unknown
95
+ }>()
96
+
97
+ const { t } = useI18n()
98
+
99
+ // ── State ──────────────────────────────────────────────────────────────
100
+
101
+ const autoId = useId()
102
+ const id = computed(() => props.inputId ?? autoId)
103
+ const inputRef = useTemplateRef<
104
+ HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
105
+ >("input")
106
+
107
+ const initialValue = props.modelValue ?? props.field.value ?? ""
108
+ const draft = ref<string>(initialValue)
109
+ const originalValue = ref<string>(initialValue)
110
+
111
+ // ── Derived ────────────────────────────────────────────────────────────
112
+
113
+ const isDisabled = computed(() => props.disabled ?? props.field.disabled ?? false)
114
+ const isRequired = computed(() => props.field.required ?? false)
115
+ const errorMessage = computed(() => props.field.error ?? null)
116
+ const hasError = computed(() => !!errorMessage.value)
117
+
118
+ const inputType = computed(() => props.field.type ?? "text")
119
+ const placeholder = computed(() => props.field.placeholder ?? undefined)
120
+ const autocomplete = computed(() => props.field.autocomplete ?? undefined)
121
+
122
+ const hasChanged = computed(() => draft.value !== originalValue.value)
123
+ const showConfirmationButtons = computed(
124
+ () => props.withConfirmation && hasChanged.value,
125
+ )
126
+
127
+ const rootClasses = computed(() => ({
128
+ "form-field": true,
129
+ [`form-field--${props.size}`]: true,
130
+ "form-field--inline": props.inline,
131
+ "form-field--disabled": isDisabled.value,
132
+ "form-field--error": hasError.value,
133
+ "form-field--with-confirmation": props.withConfirmation,
134
+ }))
135
+
136
+ const inputClasses = computed(() => ({
137
+ "form-field__input": true,
138
+ "form-field__input--fullwidth": props.fullWidth,
139
+ "form-field__input--error": hasError.value,
140
+ }))
141
+
142
+ // ── Watchers ───────────────────────────────────────────────────────────
143
+
144
+ // External v-model change → sync internal draft + confirmation baseline.
145
+ watch(
146
+ () => props.modelValue,
147
+ (v) => {
148
+ if (v !== undefined && v !== draft.value) {
149
+ draft.value = v
150
+ originalValue.value = v
151
+ }
152
+ },
153
+ )
154
+
155
+ // External field.value change → sync only when no v-model is in use.
156
+ watch(
157
+ () => props.field.value,
158
+ (v) => {
159
+ if (props.modelValue === undefined && v !== undefined && v !== draft.value) {
160
+ draft.value = v
161
+ originalValue.value = v
162
+ }
163
+ },
164
+ )
165
+
166
+ // ── Handlers ───────────────────────────────────────────────────────────
167
+
168
+ function onInput(): void {
169
+ // In confirmation mode the parent only sees the value via apply().
170
+ if (props.withConfirmation) return
171
+ emit("update:modelValue", draft.value)
172
+ emit("input", draft.value)
173
+ }
174
+
175
+ function apply(): void {
176
+ if (!hasChanged.value) return
177
+ originalValue.value = draft.value
178
+ emit("update:modelValue", draft.value)
179
+ emit("input", draft.value)
180
+ emit("on-confirm")
181
+ }
182
+
183
+ function cancel(): void {
184
+ if (hasChanged.value) draft.value = originalValue.value
185
+ emit("on-cancel")
186
+ }
187
+
188
+ function onKeydown(e: KeyboardEvent): void {
189
+ emit("keydown", e)
190
+ if (!props.withConfirmation || e.defaultPrevented) return
191
+ if (e.key === "Enter" && hasChanged.value) {
192
+ e.preventDefault()
193
+ apply()
194
+ } else if (e.key === "Escape") {
195
+ e.preventDefault()
196
+ cancel()
197
+ }
198
+ }
199
+
200
+ // ── Lifecycle ──────────────────────────────────────────────────────────
201
+
202
+ onMounted(() => {
203
+ if (props.focus) inputRef.value?.focus()
204
+
205
+ if (import.meta.env.DEV) {
206
+ const hasLabel = !!props.field.label
207
+ const hasAriaLabel =
208
+ typeof props.field.customParams?.["aria-label"] === "string"
209
+ if (!hasLabel && !hasAriaLabel) {
210
+ console.warn(
211
+ "[FormInput] missing accessible label: provide either `field.label` or `customParams['aria-label']`.",
212
+ )
213
+ }
214
+ }
215
+ })
216
+
217
+ // ── Exposed ────────────────────────────────────────────────────────────
218
+
219
+ defineExpose({
220
+ focus: () => inputRef.value?.focus(),
221
+ blur: () => inputRef.value?.blur(),
222
+ select: () =>
223
+ (inputRef.value as HTMLInputElement | HTMLTextAreaElement | null)?.select(),
224
+ })
225
+ </script>
226
+
227
+ <template>
228
+ <div :class="rootClasses">
229
+ <div v-if="field.label" class="form-field__header">
230
+ <label class="form-field__label" :for="id">
231
+ {{ field.label }}
232
+ <span v-if="isRequired" class="form-field__required" aria-hidden="true"
233
+ >*</span
234
+ >
235
+ </label>
236
+ <slot name="content-after-label" />
237
+ </div>
238
+
239
+ <div class="form-field__input-wrapper">
240
+ <slot />
241
+
242
+ <slot
243
+ v-if="$slots['custom-input']"
244
+ name="custom-input"
245
+ :id="id"
246
+ :disabled="isDisabled" />
247
+
248
+ <!-- TODO readonly mode: render plain text (<div> or <pre> when `code`) -->
249
+ <!-- TODO textarea mode: render <textarea> instead. -->
250
+
251
+ <select
252
+ v-else-if="select"
253
+ ref="input"
254
+ v-model="draft"
255
+ :class="[inputClasses, 'form-field__input--select']"
256
+ :id="id"
257
+ :disabled="isDisabled"
258
+ :required="isRequired"
259
+ :aria-required="isRequired || undefined"
260
+ :aria-invalid="hasError || undefined"
261
+ :aria-describedby="hasError ? `${id}-error` : undefined"
262
+ v-bind="field.customParams"
263
+ @change="onInput"
264
+ @keydown="onKeydown"
265
+ @blur="emit('blur', $event)"
266
+ @focus="emit('focus', $event)">
267
+ <option v-for="opt in options" :key="opt.value" :value="opt.value">
268
+ {{ opt.label }}
269
+ </option>
270
+ </select>
271
+
272
+ <input
273
+ v-else
274
+ ref="input"
275
+ v-model="draft"
276
+ :class="inputClasses"
277
+ :type="inputType"
278
+ :id="id"
279
+ :disabled="isDisabled"
280
+ :readonly="readonly"
281
+ :placeholder="placeholder"
282
+ :autocomplete="autocomplete"
283
+ :required="isRequired"
284
+ :aria-required="isRequired || undefined"
285
+ :aria-invalid="hasError || undefined"
286
+ :aria-describedby="hasError ? `${id}-error` : undefined"
287
+ v-bind="field.customParams"
288
+ @input="onInput"
289
+ @keydown="onKeydown"
290
+ @blur="emit('blur', $event)"
291
+ @focus="emit('focus', $event)" />
292
+
293
+ <div
294
+ v-if="showConfirmationButtons"
295
+ class="form-field__actions">
296
+ <Button
297
+ icon="x"
298
+ variant="tertiary"
299
+ :size="size"
300
+ :aria-label="t('form.cancel')"
301
+ @mousedown.prevent
302
+ @click="cancel" />
303
+ <Button
304
+ icon="check"
305
+ variant="primary"
306
+ :size="size"
307
+ :aria-label="t('form.apply')"
308
+ @mousedown.prevent
309
+ @click="apply" />
310
+ </div>
311
+ <div
312
+ v-else-if="withConfirmation"
313
+ class="form-field__actions form-field__actions--placeholder"
314
+ aria-hidden="true" />
315
+
316
+ <slot name="content-after-input" />
317
+ </div>
318
+
319
+ <slot name="content-bottom-input" />
320
+
321
+ <div v-if="hasError" :id="`${id}-error`" class="form-field__info">
322
+ <span class="form-field__error">{{ errorMessage }}</span>
323
+ </div>
324
+ </div>
325
+ </template>
326
+
327
+ <style scoped>
328
+ /* ── Root ──────────────────────────────────────────────────────────── */
329
+
330
+ .form-field {
331
+ --field-height: 40px;
332
+ --field-padding-x: var(--spacing-md);
333
+ --field-font-size: var(--font-size-sm);
334
+
335
+ display: flex;
336
+ flex-direction: column;
337
+ gap: var(--spacing-xs);
338
+ width: 100%;
339
+ }
340
+
341
+ .form-field--sm {
342
+ --field-height: 32px;
343
+ --field-padding-x: var(--spacing-sm);
344
+ --field-font-size: var(--font-size-xs);
345
+ }
346
+
347
+ .form-field--lg {
348
+ --field-height: 44px;
349
+ --field-padding-x: var(--spacing-md);
350
+ --field-font-size: var(--font-size-base);
351
+ }
352
+
353
+ .form-field--disabled {
354
+ opacity: 0.7;
355
+ }
356
+
357
+ /* ── Header (label row) ────────────────────────────────────────────── */
358
+
359
+ .form-field__header {
360
+ display: flex;
361
+ align-items: center;
362
+ justify-content: space-between;
363
+ gap: var(--spacing-sm);
364
+ }
365
+
366
+ .form-field__label {
367
+ display: block;
368
+ margin: 0;
369
+ font-size: var(--font-size-sm);
370
+ font-weight: 600;
371
+ line-height: 1.2;
372
+ color: var(--color-text-primary);
373
+ }
374
+
375
+ .form-field--error .form-field__label {
376
+ color: var(--color-danger);
377
+ }
378
+
379
+ .form-field__required {
380
+ margin-left: 2px;
381
+ color: var(--color-danger);
382
+ }
383
+
384
+ /* ── Input wrapper ─────────────────────────────────────────────────── */
385
+
386
+ .form-field__input-wrapper {
387
+ display: flex;
388
+ align-items: flex-start;
389
+ gap: var(--spacing-sm);
390
+ width: 100%;
391
+ }
392
+
393
+ /* ── Input ─────────────────────────────────────────────────────────── */
394
+
395
+ .form-field__input {
396
+ flex: 1;
397
+ box-sizing: border-box;
398
+ height: var(--field-height);
399
+ padding: 0 var(--field-padding-x);
400
+ font-family: inherit;
401
+ font-size: var(--field-font-size);
402
+ line-height: 1.4;
403
+ color: var(--color-text-primary);
404
+ background-color: var(--color-background);
405
+ border: 1px solid var(--color-border);
406
+ border-radius: var(--radius-sm);
407
+ outline: none;
408
+ transition:
409
+ border-color var(--transition-duration),
410
+ box-shadow var(--transition-duration);
411
+ }
412
+
413
+ .form-field__input::placeholder {
414
+ color: var(--color-text-muted);
415
+ opacity: 1;
416
+ }
417
+
418
+ .form-field__input:hover:not(:disabled) {
419
+ border-color: var(--color-text-muted);
420
+ }
421
+
422
+ .form-field__input:focus-visible {
423
+ border-color: var(--color-primary);
424
+ box-shadow: 0 0 0 3px
425
+ color-mix(in srgb, var(--color-primary) 20%, transparent);
426
+ }
427
+
428
+ .form-field__input:disabled {
429
+ cursor: not-allowed;
430
+ background-color: var(--color-surface);
431
+ color: var(--color-text-muted);
432
+ }
433
+
434
+ .form-field__input--fullwidth {
435
+ width: 100%;
436
+ max-width: none;
437
+ }
438
+
439
+ .form-field__input--select {
440
+ cursor: pointer;
441
+ appearance: auto;
442
+ }
443
+
444
+ .form-field__input--error {
445
+ border-color: var(--color-danger);
446
+ }
447
+
448
+ .form-field__input--error:focus-visible {
449
+ border-color: var(--color-danger);
450
+ box-shadow: 0 0 0 3px
451
+ color-mix(in srgb, var(--color-danger) 20%, transparent);
452
+ }
453
+
454
+ /* ── Confirmation actions ──────────────────────────────────────────── */
455
+
456
+ .form-field__actions {
457
+ display: flex;
458
+ align-items: flex-start;
459
+ gap: var(--spacing-xs);
460
+ flex-shrink: 0;
461
+ }
462
+
463
+ .form-field__actions--placeholder {
464
+ /* Reserve space so showing the buttons doesn't shift layout. */
465
+ width: calc(var(--field-height) * 2 + var(--spacing-xs));
466
+ height: var(--field-height);
467
+ pointer-events: none;
468
+ opacity: 0;
469
+ }
470
+
471
+ /* ── Info / error ──────────────────────────────────────────────────── */
472
+
473
+ .form-field__info {
474
+ display: flex;
475
+ align-items: center;
476
+ gap: var(--spacing-sm);
477
+ }
478
+
479
+ .form-field__error {
480
+ margin: 0;
481
+ font-size: var(--font-size-xs);
482
+ line-height: 1.2;
483
+ color: var(--color-danger);
484
+ }
485
+
486
+ /* ── Inline layout ─────────────────────────────────────────────────── */
487
+
488
+ .form-field--inline {
489
+ flex-direction: row;
490
+ align-items: center;
491
+ gap: var(--spacing-md);
492
+ }
493
+
494
+ .form-field--inline .form-field__header {
495
+ flex-shrink: 0;
496
+ min-width: 120px;
497
+ }
498
+
499
+ .form-field--inline .form-field__input-wrapper {
500
+ flex: 1;
501
+ }
502
+
503
+ /* ── Reduced motion ────────────────────────────────────────────────── */
504
+
505
+ @media (prefers-reduced-motion: reduce) {
506
+ .form-field__input {
507
+ transition: none;
508
+ }
509
+ }
510
+ </style>
@@ -0,0 +1,43 @@
1
+ <script setup lang="ts">
2
+ import { computed } from "vue"
3
+ import PopoverList from "../atoms/PopoverList.vue"
4
+ import Button from "../atoms/Button.vue"
5
+ import { useI18n } from "@linto-ai/transcript-ui-i18n"
6
+
7
+ interface MenuAction {
8
+ id: "merge"
9
+ label: string
10
+ }
11
+
12
+ const emit = defineEmits<{
13
+ merge: []
14
+ }>()
15
+
16
+ const { t } = useI18n()
17
+
18
+ const items = computed<MenuAction[]>(() => [
19
+ { id: "merge", label: t("speakerMenu.merge") },
20
+ ])
21
+
22
+ function onSelect(action: MenuAction): void {
23
+ if (action.id === "merge") emit("merge")
24
+ }
25
+ </script>
26
+
27
+ <template>
28
+ <PopoverList
29
+ :items="items"
30
+ :item-key="(a) => a.id"
31
+ align="end"
32
+ @select="onSelect">
33
+ <template #trigger>
34
+ <Button
35
+ icon="more-vertical"
36
+ variant="transparent"
37
+ :aria-label="t('speakerMenu.openMenu')" />
38
+ </template>
39
+ <template #item="{ item }">
40
+ <span>{{ item.label }}</span>
41
+ </template>
42
+ </PopoverList>
43
+ </template>
@@ -0,0 +1,119 @@
1
+ <script setup lang="ts" generic="T extends string">
2
+ import EditorIcon from "../atoms/EditorIcon.vue"
3
+ import Badge from "../atoms/Badge.vue"
4
+ import { resolveIcon } from "../atoms/icons"
5
+
6
+ export interface TabItem<V extends string = string> {
7
+ value: V
8
+ label: string
9
+ icon?: string
10
+ badge?: string
11
+ disabled?: boolean
12
+ }
13
+
14
+ const props = defineProps<{
15
+ tabs: TabItem<T>[]
16
+ modelValue: T | null
17
+ ariaLabel?: string
18
+ }>()
19
+
20
+ const emit = defineEmits<{
21
+ "update:modelValue": [value: T]
22
+ }>()
23
+
24
+ function onSelect(tab: TabItem<T>): void {
25
+ if (tab.disabled) return
26
+ if (tab.value === props.modelValue) return
27
+ emit("update:modelValue", tab.value)
28
+ }
29
+ </script>
30
+
31
+ <template>
32
+ <div class="tabs" role="tablist" :aria-label="ariaLabel">
33
+ <button
34
+ v-for="tab in tabs"
35
+ :key="tab.value"
36
+ type="button"
37
+ role="tab"
38
+ class="tab"
39
+ :class="{ 'tab--active': tab.value === modelValue }"
40
+ :aria-selected="tab.value === modelValue"
41
+ :aria-disabled="tab.disabled || undefined"
42
+ :disabled="tab.disabled"
43
+ @click="onSelect(tab)">
44
+ <EditorIcon
45
+ v-if="resolveIcon(tab.icon)"
46
+ :name="tab.icon!"
47
+ :size="16"
48
+ class="tab__icon" />
49
+ <span class="tab__label">{{ tab.label }}</span>
50
+ <Badge v-if="tab.badge" class="tab__badge">{{ tab.badge }}</Badge>
51
+ </button>
52
+ </div>
53
+ </template>
54
+
55
+ <style scoped>
56
+ .tabs {
57
+ display: flex;
58
+ align-items: stretch;
59
+ gap: var(--spacing-xs);
60
+ padding: 0 var(--spacing-lg);
61
+ border-bottom: 1px solid var(--color-border);
62
+ background-color: var(--color-surface);
63
+ overflow-x: auto;
64
+ scrollbar-width: thin;
65
+ }
66
+
67
+ .tab {
68
+ all: unset;
69
+ box-sizing: border-box;
70
+ display: inline-flex;
71
+ align-items: center;
72
+ gap: var(--spacing-xs);
73
+ height: 44px;
74
+ padding: 0 var(--spacing-sm);
75
+ font-family: var(--font-family);
76
+ font-size: var(--font-size-sm);
77
+ font-weight: 500;
78
+ color: var(--color-text-secondary);
79
+ cursor: pointer;
80
+ white-space: nowrap;
81
+ border-bottom: 2px solid transparent;
82
+ transition:
83
+ color var(--transition-duration),
84
+ border-color var(--transition-duration);
85
+ }
86
+
87
+ .tab:hover:not([disabled]) {
88
+ color: var(--color-text-primary);
89
+ }
90
+
91
+ .tab:focus-visible {
92
+ outline: 2px solid var(--color-primary);
93
+ outline-offset: -2px;
94
+ border-radius: var(--radius-sm);
95
+ }
96
+
97
+ .tab--active {
98
+ color: var(--color-text-primary);
99
+ border-bottom-color: var(--color-primary);
100
+ }
101
+
102
+ .tab[disabled] {
103
+ opacity: 0.4;
104
+ cursor: not-allowed;
105
+ }
106
+
107
+ .tab__icon {
108
+ flex-shrink: 0;
109
+ color: currentColor;
110
+ }
111
+
112
+ .tab__label {
113
+ text-box: cap alphabetic;
114
+ }
115
+
116
+ .tab__badge {
117
+ margin-left: var(--spacing-xs);
118
+ }
119
+ </style>