@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.
- package/LICENSE +661 -0
- package/README.md +17 -0
- package/package.json +55 -0
- package/src/atoms/Badge.vue +26 -0
- package/src/atoms/Button.vue +264 -0
- package/src/atoms/CodeBlock.vue +98 -0
- package/src/atoms/CopyButton.vue +98 -0
- package/src/atoms/EditableText.vue +115 -0
- package/src/atoms/EditorCheckbox.vue +66 -0
- package/src/atoms/EditorIcon.vue +59 -0
- package/src/atoms/MarkdownEditor.vue +542 -0
- package/src/atoms/MarkdownView.vue +159 -0
- package/src/atoms/PopoverList.vue +92 -0
- package/src/atoms/SelectableListItem.vue +183 -0
- package/src/atoms/SpeakerIndicator.vue +19 -0
- package/src/atoms/SwitchToggle.vue +90 -0
- package/src/atoms/UserAvatar.vue +38 -0
- package/src/atoms/icons.ts +108 -0
- package/src/index.ts +36 -0
- package/src/molecules/DocumentArticle.vue +169 -0
- package/src/molecules/DownloadMenu.vue +48 -0
- package/src/molecules/FormInput.vue +510 -0
- package/src/molecules/SpeakerMenu.vue +43 -0
- package/src/molecules/Tabs.vue +119 -0
- package/src/molecules/TurnTextEditor.vue +93 -0
- package/src/styles/base.css +110 -0
- package/src/styles/fonts.css +45 -0
- package/src/styles/popover-list.css +67 -0
- package/src/styles/variables.css +131 -0
- package/src/turndown-plugin-gfm.d.ts +10 -0
- package/src/utils/computeInitials.ts +10 -0
- package/src/utils/computeSelectionOffset.ts +10 -0
- package/src/utils/computeTextOffsetInContainer.ts +54 -0
- package/src/utils/highlight.ts +46 -0
- package/src/utils/markdown.ts +56 -0
- package/src/utils/placeCaretAt.ts +23 -0
- package/src/utils/shadowAwareSelection.ts +52 -0
- package/src/utils/test/computeInitials.test.ts +15 -0
- package/src/utils/test/computeTextOffsetInContainer.test.ts +53 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from "vue"
|
|
3
|
+
import { renderMarkdownSegments } from "../utils/markdown"
|
|
4
|
+
import CodeBlock from "./CodeBlock.vue"
|
|
5
|
+
|
|
6
|
+
const props = defineProps<{
|
|
7
|
+
source: string
|
|
8
|
+
streaming?: boolean
|
|
9
|
+
}>()
|
|
10
|
+
|
|
11
|
+
const segments = computed(() => renderMarkdownSegments(props.source))
|
|
12
|
+
</script>
|
|
13
|
+
|
|
14
|
+
<template>
|
|
15
|
+
<div class="markdown-view">
|
|
16
|
+
<template v-for="(seg, i) in segments" :key="i">
|
|
17
|
+
<div
|
|
18
|
+
v-if="seg.type === 'html'"
|
|
19
|
+
class="markdown-view__html"
|
|
20
|
+
v-html="seg.html" />
|
|
21
|
+
<CodeBlock
|
|
22
|
+
v-else
|
|
23
|
+
:code="seg.code"
|
|
24
|
+
:lang="seg.lang"
|
|
25
|
+
:streaming="streaming" />
|
|
26
|
+
</template>
|
|
27
|
+
</div>
|
|
28
|
+
</template>
|
|
29
|
+
|
|
30
|
+
<style scoped>
|
|
31
|
+
.markdown-view {
|
|
32
|
+
font-family: var(--font-family);
|
|
33
|
+
font-size: var(--font-size-base);
|
|
34
|
+
line-height: var(--line-height);
|
|
35
|
+
color: var(--color-text-primary);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/* No box: block children flow as direct children of .markdown-view so margin
|
|
39
|
+
collapsing keeps working across html/code segment boundaries. */
|
|
40
|
+
.markdown-view__html {
|
|
41
|
+
display: contents;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/* Trim the outer margins of the first/last rendered block (reach through the
|
|
45
|
+
display:contents wrapper to the real content element). */
|
|
46
|
+
.markdown-view > .markdown-view__html:first-child > :deep(:first-child) {
|
|
47
|
+
margin-top: 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.markdown-view > .markdown-view__html:last-child > :deep(:last-child) {
|
|
51
|
+
margin-bottom: 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.markdown-view :deep(h1),
|
|
55
|
+
.markdown-view :deep(h2),
|
|
56
|
+
.markdown-view :deep(h3),
|
|
57
|
+
.markdown-view :deep(h4) {
|
|
58
|
+
margin: var(--spacing-lg) 0 var(--spacing-sm);
|
|
59
|
+
font-weight: 700;
|
|
60
|
+
color: var(--color-text-primary);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.markdown-view :deep(h1) {
|
|
64
|
+
font-size: var(--font-size-xl);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.markdown-view :deep(h2) {
|
|
68
|
+
font-size: var(--font-size-lg);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.markdown-view :deep(h3) {
|
|
72
|
+
font-size: var(--font-size-base);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
.markdown-view :deep(h4) {
|
|
76
|
+
font-size: var(--font-size-sm);
|
|
77
|
+
text-transform: uppercase;
|
|
78
|
+
letter-spacing: 0.05em;
|
|
79
|
+
color: var(--color-text-secondary);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.markdown-view :deep(p) {
|
|
83
|
+
margin: 0 0 var(--spacing-md);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.markdown-view :deep(ul),
|
|
87
|
+
.markdown-view :deep(ol) {
|
|
88
|
+
margin: 0 0 var(--spacing-md);
|
|
89
|
+
padding-left: var(--spacing-lg);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.markdown-view :deep(li) {
|
|
93
|
+
margin: var(--spacing-xs) 0;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
.markdown-view :deep(blockquote) {
|
|
97
|
+
margin: var(--spacing-md) 0;
|
|
98
|
+
padding: var(--spacing-sm) var(--spacing-md);
|
|
99
|
+
border-left: 3px solid var(--color-border);
|
|
100
|
+
color: var(--color-text-secondary);
|
|
101
|
+
font-style: italic;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/* Inline code only — fenced blocks render via <CodeBlock>. Scoped to the html
|
|
105
|
+
wrapper so it never reaches into the CodeBlock component. */
|
|
106
|
+
.markdown-view__html :deep(code) {
|
|
107
|
+
font-family: var(--font-family-mono);
|
|
108
|
+
font-size: 0.9em;
|
|
109
|
+
padding: 1px 4px;
|
|
110
|
+
background-color: var(--color-surface);
|
|
111
|
+
border-radius: var(--radius-sm);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/* Fallback for raw <pre> written as literal HTML (not fenced code). */
|
|
115
|
+
.markdown-view__html :deep(pre) {
|
|
116
|
+
margin: var(--spacing-md) 0;
|
|
117
|
+
padding: var(--spacing-md);
|
|
118
|
+
background-color: var(--color-surface);
|
|
119
|
+
border-radius: var(--radius-md);
|
|
120
|
+
overflow-x: auto;
|
|
121
|
+
border: 1px solid var(--color-border);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.markdown-view__html :deep(pre code) {
|
|
125
|
+
padding: 0;
|
|
126
|
+
background: none;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
.markdown-view :deep(a) {
|
|
130
|
+
color: var(--color-primary);
|
|
131
|
+
text-decoration: underline;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.markdown-view :deep(hr) {
|
|
135
|
+
border: 0;
|
|
136
|
+
border-top: 1px solid var(--color-border);
|
|
137
|
+
margin: var(--spacing-lg) 0;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.markdown-view :deep(strong) {
|
|
141
|
+
font-weight: 700;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
.markdown-view :deep(table) {
|
|
145
|
+
border-collapse: collapse;
|
|
146
|
+
margin: var(--spacing-md) 0;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
.markdown-view :deep(th),
|
|
150
|
+
.markdown-view :deep(td) {
|
|
151
|
+
border: 1px solid var(--color-border);
|
|
152
|
+
padding: var(--spacing-xs) var(--spacing-sm);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
.markdown-view :deep(th) {
|
|
156
|
+
background-color: var(--color-surface);
|
|
157
|
+
font-weight: 600;
|
|
158
|
+
}
|
|
159
|
+
</style>
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
<script setup lang="ts" generic="T">
|
|
2
|
+
import { computed, ref } from "vue"
|
|
3
|
+
import {
|
|
4
|
+
DropdownMenuRoot,
|
|
5
|
+
DropdownMenuTrigger,
|
|
6
|
+
DropdownMenuPortal,
|
|
7
|
+
DropdownMenuContent,
|
|
8
|
+
DropdownMenuItem,
|
|
9
|
+
} from "reka-ui"
|
|
10
|
+
|
|
11
|
+
const props = withDefaults(
|
|
12
|
+
defineProps<{
|
|
13
|
+
items: T[]
|
|
14
|
+
itemKey?: (item: T) => string | number
|
|
15
|
+
isCurrent?: (item: T) => boolean
|
|
16
|
+
align?: "start" | "center" | "end"
|
|
17
|
+
side?: "top" | "right" | "bottom" | "left"
|
|
18
|
+
sideOffset?: number
|
|
19
|
+
open?: boolean
|
|
20
|
+
}>(),
|
|
21
|
+
{
|
|
22
|
+
align: "start",
|
|
23
|
+
side: "bottom",
|
|
24
|
+
sideOffset: 4,
|
|
25
|
+
open: undefined,
|
|
26
|
+
},
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
const emit = defineEmits<{
|
|
30
|
+
select: [item: T]
|
|
31
|
+
"update:open": [value: boolean]
|
|
32
|
+
}>()
|
|
33
|
+
|
|
34
|
+
const internalOpen = ref(false)
|
|
35
|
+
const resolvedOpen = computed({
|
|
36
|
+
get: () => {
|
|
37
|
+
// Controlled: honour the bound `open` prop; uncontrolled: use internal state.
|
|
38
|
+
if (props.open !== undefined) {
|
|
39
|
+
return props.open
|
|
40
|
+
}
|
|
41
|
+
return internalOpen.value
|
|
42
|
+
},
|
|
43
|
+
set: (v) => {
|
|
44
|
+
internalOpen.value = v
|
|
45
|
+
emit("update:open", v)
|
|
46
|
+
},
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
defineSlots<{
|
|
50
|
+
trigger?: () => unknown
|
|
51
|
+
item: (slotProps: { item: T }) => unknown
|
|
52
|
+
footer?: () => unknown
|
|
53
|
+
}>()
|
|
54
|
+
|
|
55
|
+
function keyFor(item: T, index: number): string | number {
|
|
56
|
+
return props.itemKey ? props.itemKey(item) : index
|
|
57
|
+
}
|
|
58
|
+
</script>
|
|
59
|
+
|
|
60
|
+
<template>
|
|
61
|
+
<DropdownMenuRoot v-model:open="resolvedOpen">
|
|
62
|
+
<DropdownMenuTrigger as-child>
|
|
63
|
+
<slot name="trigger" />
|
|
64
|
+
</DropdownMenuTrigger>
|
|
65
|
+
<DropdownMenuPortal disabled>
|
|
66
|
+
<DropdownMenuContent
|
|
67
|
+
class="popover-list"
|
|
68
|
+
position-strategy="absolute"
|
|
69
|
+
:side="side"
|
|
70
|
+
:align="align"
|
|
71
|
+
:side-offset="sideOffset">
|
|
72
|
+
<ul v-if="items.length > 0" class="popover-list__items">
|
|
73
|
+
<DropdownMenuItem
|
|
74
|
+
v-for="(item, index) in items"
|
|
75
|
+
:key="keyFor(item, index)"
|
|
76
|
+
as="li"
|
|
77
|
+
class="popover-list__item"
|
|
78
|
+
:class="{ 'popover-list__item--current': isCurrent?.(item) }"
|
|
79
|
+
@select="emit('select', item)">
|
|
80
|
+
<slot name="item" :item="item" />
|
|
81
|
+
</DropdownMenuItem>
|
|
82
|
+
</ul>
|
|
83
|
+
<template v-if="$slots.footer">
|
|
84
|
+
<div v-if="items.length > 0" class="popover-list__divider" />
|
|
85
|
+
<div class="popover-list__footer">
|
|
86
|
+
<slot name="footer" />
|
|
87
|
+
</div>
|
|
88
|
+
</template>
|
|
89
|
+
</DropdownMenuContent>
|
|
90
|
+
</DropdownMenuPortal>
|
|
91
|
+
</DropdownMenuRoot>
|
|
92
|
+
</template>
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
withDefaults(
|
|
3
|
+
defineProps<{
|
|
4
|
+
/** Marks the row as selected — sets aria-current and the active style. */
|
|
5
|
+
current?: boolean
|
|
6
|
+
disabled?: boolean
|
|
7
|
+
/** Convenience for a plain text label (or use the default slot). */
|
|
8
|
+
label?: string
|
|
9
|
+
size?: "sm" | "md"
|
|
10
|
+
}>(),
|
|
11
|
+
{ size: "md" },
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
const emit = defineEmits<{
|
|
15
|
+
select: []
|
|
16
|
+
}>()
|
|
17
|
+
</script>
|
|
18
|
+
|
|
19
|
+
<template>
|
|
20
|
+
<!-- Row owns the background so the selectable button AND the trailing
|
|
21
|
+
actions share the same hover / selected surface. -->
|
|
22
|
+
<div
|
|
23
|
+
class="selectable-list-item"
|
|
24
|
+
:class="[
|
|
25
|
+
`selectable-list-item--${size}`,
|
|
26
|
+
{ 'selectable-list-item--current': current },
|
|
27
|
+
]">
|
|
28
|
+
<button
|
|
29
|
+
type="button"
|
|
30
|
+
class="selectable-list-item__main"
|
|
31
|
+
:disabled="disabled"
|
|
32
|
+
:aria-current="current ? 'true' : undefined"
|
|
33
|
+
@click="emit('select')">
|
|
34
|
+
<span v-if="$slots.leading" class="selectable-list-item__leading">
|
|
35
|
+
<slot name="leading" />
|
|
36
|
+
</span>
|
|
37
|
+
<span class="selectable-list-item__label">
|
|
38
|
+
<slot>{{ label }}</slot>
|
|
39
|
+
</span>
|
|
40
|
+
<span v-if="$slots.trailing" class="selectable-list-item__trailing">
|
|
41
|
+
<slot name="trailing" />
|
|
42
|
+
</span>
|
|
43
|
+
</button>
|
|
44
|
+
|
|
45
|
+
<!-- Absolutely positioned: no flow space taken (label keeps full width),
|
|
46
|
+
revealed on hover / keyboard focus, overlaying the row surface. -->
|
|
47
|
+
<div v-if="$slots.actions" class="selectable-list-item__actions">
|
|
48
|
+
<slot name="actions" />
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
</template>
|
|
52
|
+
|
|
53
|
+
<style scoped>
|
|
54
|
+
.selectable-list-item {
|
|
55
|
+
position: relative;
|
|
56
|
+
display: flex;
|
|
57
|
+
border: 1px solid transparent;
|
|
58
|
+
transition:
|
|
59
|
+
background-color var(--transition-duration),
|
|
60
|
+
box-shadow var(--transition-duration);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.selectable-list-item--md {
|
|
64
|
+
font-size: var(--font-size-sm);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.selectable-list-item--sm {
|
|
68
|
+
font-size: var(--font-size-xs);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.selectable-list-item:hover {
|
|
72
|
+
background-color: var(--color-surface-hover);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
.selectable-list-item--current,
|
|
76
|
+
.selectable-list-item--current:hover {
|
|
77
|
+
background-color: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
|
78
|
+
box-shadow: inset 2px 0 0 var(--color-primary);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/* ── The selectable button ── */
|
|
82
|
+
.selectable-list-item__main {
|
|
83
|
+
flex: 1;
|
|
84
|
+
min-width: 0;
|
|
85
|
+
display: flex;
|
|
86
|
+
align-items: center;
|
|
87
|
+
gap: var(--spacing-sm);
|
|
88
|
+
background: none;
|
|
89
|
+
border: none;
|
|
90
|
+
font: inherit;
|
|
91
|
+
color: var(--color-text-primary);
|
|
92
|
+
text-align: left;
|
|
93
|
+
cursor: pointer;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
.selectable-list-item--md .selectable-list-item__main {
|
|
97
|
+
padding: var(--spacing-sm);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
.selectable-list-item--sm .selectable-list-item__main {
|
|
101
|
+
padding: var(--spacing-xs) var(--spacing-sm);
|
|
102
|
+
color: var(--color-text-secondary);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.selectable-list-item__main:focus-visible {
|
|
106
|
+
outline: 2px solid var(--color-primary);
|
|
107
|
+
outline-offset: -2px;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.selectable-list-item__main:disabled {
|
|
111
|
+
cursor: not-allowed;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
.selectable-list-item--current .selectable-list-item__main {
|
|
115
|
+
color: var(--color-primary);
|
|
116
|
+
font-weight: 600;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
.selectable-list-item__label {
|
|
120
|
+
flex: 1;
|
|
121
|
+
min-width: 0;
|
|
122
|
+
overflow: hidden;
|
|
123
|
+
text-overflow: ellipsis;
|
|
124
|
+
white-space: nowrap;
|
|
125
|
+
font-variant-numeric: tabular-nums;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
.selectable-list-item__leading,
|
|
129
|
+
.selectable-list-item__trailing {
|
|
130
|
+
display: inline-flex;
|
|
131
|
+
align-items: center;
|
|
132
|
+
flex-shrink: 0;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/* Trailing content (hints, dates) stays muted even on the active row. */
|
|
136
|
+
.selectable-list-item__trailing {
|
|
137
|
+
font-size: var(--font-size-xs);
|
|
138
|
+
color: var(--color-text-muted);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/* ── Trailing actions (hover / focus reveal, overlaying the row) ── */
|
|
142
|
+
.selectable-list-item__actions {
|
|
143
|
+
position: absolute;
|
|
144
|
+
inset-block: 0;
|
|
145
|
+
inset-inline-end: 0;
|
|
146
|
+
display: flex;
|
|
147
|
+
align-items: center;
|
|
148
|
+
gap: 2px;
|
|
149
|
+
padding-inline: var(--spacing-md) var(--spacing-xs);
|
|
150
|
+
/* Fade the label out behind the actions, matching the row surface. */
|
|
151
|
+
background: linear-gradient(
|
|
152
|
+
to right,
|
|
153
|
+
transparent,
|
|
154
|
+
var(--color-surface-hover) var(--spacing-md)
|
|
155
|
+
);
|
|
156
|
+
opacity: 0;
|
|
157
|
+
pointer-events: none;
|
|
158
|
+
transition: opacity var(--transition-duration);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
.selectable-list-item:hover .selectable-list-item__actions,
|
|
162
|
+
.selectable-list-item:focus-within .selectable-list-item__actions {
|
|
163
|
+
opacity: 1;
|
|
164
|
+
pointer-events: auto;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/* Match the fade to the selected surface on the active row. */
|
|
168
|
+
.selectable-list-item--current .selectable-list-item__actions {
|
|
169
|
+
background: linear-gradient(
|
|
170
|
+
to right,
|
|
171
|
+
transparent,
|
|
172
|
+
color-mix(in srgb, var(--color-primary) 12%, var(--color-surface-hover))
|
|
173
|
+
var(--spacing-md)
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
@media (prefers-reduced-motion: reduce) {
|
|
178
|
+
.selectable-list-item,
|
|
179
|
+
.selectable-list-item__actions {
|
|
180
|
+
transition: none;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
</style>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<span class="speaker-indicator" :style="{ backgroundColor: color }" aria-hidden="true" />
|
|
3
|
+
</template>
|
|
4
|
+
|
|
5
|
+
<script setup lang="ts">
|
|
6
|
+
defineProps<{
|
|
7
|
+
color: string
|
|
8
|
+
}>()
|
|
9
|
+
</script>
|
|
10
|
+
|
|
11
|
+
<style scoped>
|
|
12
|
+
.speaker-indicator {
|
|
13
|
+
display: inline-block;
|
|
14
|
+
width: 8px;
|
|
15
|
+
height: 8px;
|
|
16
|
+
border-radius: 50%;
|
|
17
|
+
flex-shrink: 0;
|
|
18
|
+
}
|
|
19
|
+
</style>
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { useId } from 'vue'
|
|
3
|
+
|
|
4
|
+
const props = withDefaults(defineProps<{
|
|
5
|
+
modelValue: boolean
|
|
6
|
+
id?: string
|
|
7
|
+
disabled?: boolean
|
|
8
|
+
}>(), {
|
|
9
|
+
id: undefined,
|
|
10
|
+
disabled: false,
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
const emit = defineEmits<{
|
|
14
|
+
'update:modelValue': [value: boolean]
|
|
15
|
+
}>()
|
|
16
|
+
|
|
17
|
+
const inputId = props.id ?? useId()
|
|
18
|
+
</script>
|
|
19
|
+
|
|
20
|
+
<template>
|
|
21
|
+
<div class="switch">
|
|
22
|
+
<input
|
|
23
|
+
type="checkbox"
|
|
24
|
+
:id="inputId"
|
|
25
|
+
:checked="modelValue"
|
|
26
|
+
:disabled="disabled"
|
|
27
|
+
@change="emit('update:modelValue', ($event.target as HTMLInputElement).checked)"
|
|
28
|
+
/>
|
|
29
|
+
<label :for="inputId">
|
|
30
|
+
<div class="switch-slider"></div>
|
|
31
|
+
</label>
|
|
32
|
+
</div>
|
|
33
|
+
</template>
|
|
34
|
+
|
|
35
|
+
<style scoped>
|
|
36
|
+
.switch {
|
|
37
|
+
display: inline-block;
|
|
38
|
+
flex-shrink: 0;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.switch input {
|
|
42
|
+
position: absolute;
|
|
43
|
+
width: 1px;
|
|
44
|
+
height: 1px;
|
|
45
|
+
padding: 0;
|
|
46
|
+
margin: -1px;
|
|
47
|
+
overflow: hidden;
|
|
48
|
+
clip-path: inset(50%);
|
|
49
|
+
white-space: nowrap;
|
|
50
|
+
border: 0;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.switch label {
|
|
54
|
+
height: 20px;
|
|
55
|
+
width: 40px;
|
|
56
|
+
display: block;
|
|
57
|
+
border: 1px solid var(--color-border);
|
|
58
|
+
border-radius: 20px;
|
|
59
|
+
cursor: pointer;
|
|
60
|
+
background-color: var(--color-border);
|
|
61
|
+
transition: background-color var(--transition-duration);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.switch .switch-slider {
|
|
65
|
+
height: 22px;
|
|
66
|
+
width: 22px;
|
|
67
|
+
border: 1px solid var(--color-border);
|
|
68
|
+
border-radius: 50%;
|
|
69
|
+
position: relative;
|
|
70
|
+
top: -2px;
|
|
71
|
+
left: -2px;
|
|
72
|
+
background-color: var(--color-white);
|
|
73
|
+
transition: left var(--transition-duration);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.switch input:checked + label {
|
|
77
|
+
background-color: var(--color-primary);
|
|
78
|
+
border-color: var(--color-primary);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.switch input:checked + label .switch-slider {
|
|
82
|
+
left: 20px;
|
|
83
|
+
border-color: var(--color-primary);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.switch input:disabled + label {
|
|
87
|
+
cursor: not-allowed;
|
|
88
|
+
opacity: 0.5;
|
|
89
|
+
}
|
|
90
|
+
</style>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from "vue"
|
|
3
|
+
import { computeInitials } from "../utils/computeInitials"
|
|
4
|
+
|
|
5
|
+
// Initials avatar for a user (not a speaker — speakers have SpeakerIndicator).
|
|
6
|
+
// The full name shows on hover (native tooltip) and to assistive tech; pass
|
|
7
|
+
// `label` when the context needs a richer sentence than the bare name.
|
|
8
|
+
const props = defineProps<{
|
|
9
|
+
name: string
|
|
10
|
+
label?: string
|
|
11
|
+
}>()
|
|
12
|
+
|
|
13
|
+
const initials = computed(() => computeInitials(props.name))
|
|
14
|
+
const title = computed(() => props.label ?? props.name)
|
|
15
|
+
</script>
|
|
16
|
+
|
|
17
|
+
<template>
|
|
18
|
+
<span class="user-avatar" role="img" :title="title" :aria-label="title">{{
|
|
19
|
+
initials
|
|
20
|
+
}}</span>
|
|
21
|
+
</template>
|
|
22
|
+
|
|
23
|
+
<style scoped>
|
|
24
|
+
.user-avatar {
|
|
25
|
+
display: inline-flex;
|
|
26
|
+
align-items: center;
|
|
27
|
+
justify-content: center;
|
|
28
|
+
width: 24px;
|
|
29
|
+
height: 24px;
|
|
30
|
+
border-radius: 50%;
|
|
31
|
+
background-color: var(--color-primary);
|
|
32
|
+
color: var(--color-white);
|
|
33
|
+
font-size: var(--font-size-xs);
|
|
34
|
+
font-weight: 600;
|
|
35
|
+
user-select: none;
|
|
36
|
+
cursor: default;
|
|
37
|
+
}
|
|
38
|
+
</style>
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { Component } from "vue"
|
|
2
|
+
import {
|
|
3
|
+
AlertTriangle,
|
|
4
|
+
ArrowDown,
|
|
5
|
+
Bold,
|
|
6
|
+
Check,
|
|
7
|
+
ChevronDown,
|
|
8
|
+
ClipboardList,
|
|
9
|
+
ClipboardType,
|
|
10
|
+
Code,
|
|
11
|
+
Code2,
|
|
12
|
+
Copy,
|
|
13
|
+
Download,
|
|
14
|
+
FileText,
|
|
15
|
+
Heading1,
|
|
16
|
+
Heading2,
|
|
17
|
+
Heading3,
|
|
18
|
+
Italic,
|
|
19
|
+
List,
|
|
20
|
+
ListOrdered,
|
|
21
|
+
Loader2,
|
|
22
|
+
Maximize,
|
|
23
|
+
Merge,
|
|
24
|
+
Minimize,
|
|
25
|
+
Table as TableIcon,
|
|
26
|
+
MessageCircle,
|
|
27
|
+
MoreVertical,
|
|
28
|
+
Pause,
|
|
29
|
+
Pencil,
|
|
30
|
+
Play,
|
|
31
|
+
Plus,
|
|
32
|
+
Quote,
|
|
33
|
+
Redo2,
|
|
34
|
+
RefreshCw,
|
|
35
|
+
Save,
|
|
36
|
+
SendHorizontal,
|
|
37
|
+
Settings,
|
|
38
|
+
SkipBack,
|
|
39
|
+
SkipForward,
|
|
40
|
+
Sparkles,
|
|
41
|
+
Trash2,
|
|
42
|
+
Undo2,
|
|
43
|
+
UserPlus,
|
|
44
|
+
Users,
|
|
45
|
+
Volume2,
|
|
46
|
+
VolumeX,
|
|
47
|
+
X,
|
|
48
|
+
} from "lucide-vue-next"
|
|
49
|
+
|
|
50
|
+
export const iconMap: Record<string, Component> = {
|
|
51
|
+
"arrow-down": ArrowDown,
|
|
52
|
+
warning: AlertTriangle,
|
|
53
|
+
bold: Bold,
|
|
54
|
+
check: Check,
|
|
55
|
+
"chevron-down": ChevronDown,
|
|
56
|
+
"clipboard-list": ClipboardList,
|
|
57
|
+
"clipboard-type": ClipboardType,
|
|
58
|
+
code: Code,
|
|
59
|
+
"code-block": Code2,
|
|
60
|
+
copy: Copy,
|
|
61
|
+
download: Download,
|
|
62
|
+
"heading-1": Heading1,
|
|
63
|
+
"heading-2": Heading2,
|
|
64
|
+
"heading-3": Heading3,
|
|
65
|
+
italic: Italic,
|
|
66
|
+
list: List,
|
|
67
|
+
"list-ordered": ListOrdered,
|
|
68
|
+
maximize: Maximize,
|
|
69
|
+
merge: Merge,
|
|
70
|
+
minimize: Minimize,
|
|
71
|
+
pause: Pause,
|
|
72
|
+
play: Play,
|
|
73
|
+
quote: Quote,
|
|
74
|
+
redo: Redo2,
|
|
75
|
+
table: TableIcon,
|
|
76
|
+
save: Save,
|
|
77
|
+
settings: Settings,
|
|
78
|
+
"skip-back": SkipBack,
|
|
79
|
+
"skip-forward": SkipForward,
|
|
80
|
+
undo: Undo2,
|
|
81
|
+
users: Users,
|
|
82
|
+
volume: Volume2,
|
|
83
|
+
"volume-mute": VolumeX,
|
|
84
|
+
x: X,
|
|
85
|
+
"circle-notch": Loader2,
|
|
86
|
+
spinner: Loader2,
|
|
87
|
+
"more-vertical": MoreVertical,
|
|
88
|
+
"user-plus": UserPlus,
|
|
89
|
+
plus: Plus,
|
|
90
|
+
pencil: Pencil,
|
|
91
|
+
trash: Trash2,
|
|
92
|
+
send: SendHorizontal,
|
|
93
|
+
"file-text": FileText,
|
|
94
|
+
"message-circle": MessageCircle,
|
|
95
|
+
"refresh-cw": RefreshCw,
|
|
96
|
+
sparkles: Sparkles,
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function resolveIcon(name?: string): Component | undefined {
|
|
100
|
+
if (!name) return undefined
|
|
101
|
+
return iconMap[name]
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export const ICON_SIZES: Record<"sm" | "md" | "lg", number> = {
|
|
105
|
+
sm: 16,
|
|
106
|
+
md: 20,
|
|
107
|
+
lg: 24,
|
|
108
|
+
}
|