@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
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@linto-ai/transcript-ui-ui",
|
|
3
|
+
"version": "0.9.0",
|
|
4
|
+
"description": "Design-system components and CSS tokens for @linto-ai/transcript-ui",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"vue",
|
|
7
|
+
"vue3",
|
|
8
|
+
"design-system",
|
|
9
|
+
"ui-kit",
|
|
10
|
+
"linto"
|
|
11
|
+
],
|
|
12
|
+
"sideEffects": [
|
|
13
|
+
"**/*.vue",
|
|
14
|
+
"**/*.css"
|
|
15
|
+
],
|
|
16
|
+
"homepage": "https://github.com/linto-ai/linto-studio",
|
|
17
|
+
"bugs": "https://github.com/linto-ai/linto-studio/issues",
|
|
18
|
+
"author": "Tom Darboux <tdarboux@linagora.com>",
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "https://github.com/linto-ai/linto-studio.git",
|
|
22
|
+
"directory": "studio-sdk/components/transcript-ui/packages/ui"
|
|
23
|
+
},
|
|
24
|
+
"type": "module",
|
|
25
|
+
"license": "AGPL-3.0",
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"src"
|
|
31
|
+
],
|
|
32
|
+
"main": "./src/index.ts",
|
|
33
|
+
"types": "./src/index.ts",
|
|
34
|
+
"exports": {
|
|
35
|
+
".": "./src/index.ts",
|
|
36
|
+
"./styles/*": "./src/styles/*"
|
|
37
|
+
},
|
|
38
|
+
"peerDependencies": {
|
|
39
|
+
"vue": "^3.5.0"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"@linto-ai/transcript-ui-i18n": "0.9.0",
|
|
43
|
+
"dompurify": "^3.2.4",
|
|
44
|
+
"lucide-vue-next": "^0.564.0",
|
|
45
|
+
"marked": "^18.0.2",
|
|
46
|
+
"prismjs": "^1.30.0",
|
|
47
|
+
"reka-ui": "^2.8.0",
|
|
48
|
+
"turndown": "^7.2.4",
|
|
49
|
+
"turndown-plugin-gfm": "^1.0.2"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@types/prismjs": "^1.26.6",
|
|
53
|
+
"@types/turndown": "^5.0.6"
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
defineProps<{
|
|
3
|
+
ariaLabel?: string
|
|
4
|
+
}>()
|
|
5
|
+
</script>
|
|
6
|
+
|
|
7
|
+
<template>
|
|
8
|
+
<span class="editor-badge" :aria-label="ariaLabel">
|
|
9
|
+
<slot />
|
|
10
|
+
</span>
|
|
11
|
+
</template>
|
|
12
|
+
|
|
13
|
+
<style scoped>
|
|
14
|
+
.editor-badge {
|
|
15
|
+
display: inline-flex;
|
|
16
|
+
align-items: center;
|
|
17
|
+
padding: 2px var(--spacing-sm);
|
|
18
|
+
font-size: var(--font-size-xs);
|
|
19
|
+
font-weight: 500;
|
|
20
|
+
color: var(--color-text-muted);
|
|
21
|
+
background-color: var(--color-surface);
|
|
22
|
+
border: 1px solid var(--color-border-light);
|
|
23
|
+
border-radius: var(--radius-sm);
|
|
24
|
+
white-space: nowrap;
|
|
25
|
+
}
|
|
26
|
+
</style>
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, useSlots } from "vue"
|
|
3
|
+
import EditorIcon from "./EditorIcon.vue"
|
|
4
|
+
import { resolveIcon, ICON_SIZES } from "./icons"
|
|
5
|
+
|
|
6
|
+
const props = withDefaults(
|
|
7
|
+
defineProps<{
|
|
8
|
+
label?: string
|
|
9
|
+
icon?: string
|
|
10
|
+
iconRight?: string
|
|
11
|
+
variant?: "primary" | "secondary" | "tertiary" | "transparent" | "inverse"
|
|
12
|
+
intent?: "default" | "destructive"
|
|
13
|
+
size?: "sm" | "md" | "lg"
|
|
14
|
+
disabled?: boolean
|
|
15
|
+
loading?: boolean
|
|
16
|
+
block?: boolean
|
|
17
|
+
type?: "button" | "submit"
|
|
18
|
+
ariaLabel?: string
|
|
19
|
+
}>(),
|
|
20
|
+
{
|
|
21
|
+
variant: "tertiary",
|
|
22
|
+
intent: "default",
|
|
23
|
+
size: "sm",
|
|
24
|
+
disabled: false,
|
|
25
|
+
loading: false,
|
|
26
|
+
block: false,
|
|
27
|
+
type: "button",
|
|
28
|
+
},
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
const slots = useSlots()
|
|
32
|
+
|
|
33
|
+
const hasResolvedIcon = computed(() => !!resolveIcon(props.icon))
|
|
34
|
+
const hasResolvedIconRight = computed(() => !!resolveIcon(props.iconRight))
|
|
35
|
+
const iconSize = computed(() => ICON_SIZES[props.size])
|
|
36
|
+
|
|
37
|
+
const isDisabled = computed(() => props.disabled || props.loading)
|
|
38
|
+
const hasLabel = computed(() => !!props.label || !!slots.default)
|
|
39
|
+
const hasLeftIcon = computed(
|
|
40
|
+
() => props.loading || hasResolvedIcon.value || !!slots.icon,
|
|
41
|
+
)
|
|
42
|
+
const isIconOnly = computed(() => hasLeftIcon.value && !hasLabel.value)
|
|
43
|
+
|
|
44
|
+
const classes = computed(() => [
|
|
45
|
+
"editor-btn",
|
|
46
|
+
`editor-btn--${props.variant}`,
|
|
47
|
+
`editor-btn--${props.intent}`,
|
|
48
|
+
`editor-btn--${props.size}`,
|
|
49
|
+
isIconOnly.value && "editor-btn--icon-only",
|
|
50
|
+
props.block && "editor-btn--block",
|
|
51
|
+
])
|
|
52
|
+
</script>
|
|
53
|
+
|
|
54
|
+
<template>
|
|
55
|
+
<button
|
|
56
|
+
:type="type"
|
|
57
|
+
:class="classes"
|
|
58
|
+
:disabled="isDisabled"
|
|
59
|
+
:aria-disabled="isDisabled"
|
|
60
|
+
:aria-label="ariaLabel">
|
|
61
|
+
<EditorIcon v-if="loading" name="spinner" spin :size="iconSize" />
|
|
62
|
+
<EditorIcon v-else-if="hasResolvedIcon" :name="icon!" :size="iconSize" />
|
|
63
|
+
<slot v-else-if="$slots.icon" name="icon" />
|
|
64
|
+
<span v-if="hasLabel" class="editor-btn__label">
|
|
65
|
+
<slot>{{ label }}</slot>
|
|
66
|
+
</span>
|
|
67
|
+
<EditorIcon
|
|
68
|
+
v-if="hasResolvedIconRight"
|
|
69
|
+
:name="iconRight!"
|
|
70
|
+
:size="iconSize" />
|
|
71
|
+
<slot v-else-if="$slots['icon-right']" name="icon-right" />
|
|
72
|
+
</button>
|
|
73
|
+
</template>
|
|
74
|
+
|
|
75
|
+
<style scoped>
|
|
76
|
+
.editor-btn {
|
|
77
|
+
/* Default tokens — overridden by variant/intent/size modifiers */
|
|
78
|
+
--btn-bg: transparent;
|
|
79
|
+
--btn-text: var(--color-text-secondary);
|
|
80
|
+
--btn-border-color: var(--color-border);
|
|
81
|
+
--btn-hover-bg: var(--color-surface-hover);
|
|
82
|
+
--btn-hover-text: var(--color-text-primary);
|
|
83
|
+
--btn-padding-y: 0;
|
|
84
|
+
--btn-padding-x: var(--spacing-sm);
|
|
85
|
+
--btn-font-size: var(--font-size-xs);
|
|
86
|
+
--btn-height: 32px;
|
|
87
|
+
--btn-gap: var(--spacing-xs);
|
|
88
|
+
|
|
89
|
+
display: inline-flex;
|
|
90
|
+
align-items: center;
|
|
91
|
+
justify-content: center;
|
|
92
|
+
gap: var(--btn-gap);
|
|
93
|
+
box-sizing: border-box;
|
|
94
|
+
height: var(--btn-height);
|
|
95
|
+
padding: var(--btn-padding-y) var(--btn-padding-x);
|
|
96
|
+
font-family: var(--font-family);
|
|
97
|
+
font-size: var(--btn-font-size);
|
|
98
|
+
font-weight: 500;
|
|
99
|
+
line-height: 1;
|
|
100
|
+
color: var(--btn-text);
|
|
101
|
+
background-color: var(--btn-bg);
|
|
102
|
+
border: 1px solid var(--btn-border-color);
|
|
103
|
+
border-radius: var(--radius-sm);
|
|
104
|
+
cursor: pointer;
|
|
105
|
+
white-space: nowrap;
|
|
106
|
+
transition:
|
|
107
|
+
background-color var(--transition-duration),
|
|
108
|
+
color var(--transition-duration),
|
|
109
|
+
border-color var(--transition-duration);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.editor-btn:hover:not(:disabled) {
|
|
113
|
+
background-color: var(--btn-hover-bg);
|
|
114
|
+
color: var(--btn-hover-text);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.editor-btn:focus-visible {
|
|
118
|
+
outline: 2px solid var(--color-primary);
|
|
119
|
+
outline-offset: 2px;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/* Note: this rule is repeated lower in the file (after variants) to win the
|
|
123
|
+
cascade on the variant CSS vars. Keep this lightweight version for the
|
|
124
|
+
cursor and hover suppression. */
|
|
125
|
+
.editor-btn:disabled {
|
|
126
|
+
cursor: not-allowed;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
.editor-btn:disabled:hover {
|
|
130
|
+
background-color: var(--btn-bg);
|
|
131
|
+
color: var(--btn-text);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.editor-btn__label {
|
|
135
|
+
/* //overflow: hidden;
|
|
136
|
+
text-overflow: ellipsis; */
|
|
137
|
+
text-overflow: ellipsis;
|
|
138
|
+
text-box: cap alphabetic;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/* Sizes */
|
|
142
|
+
.editor-btn--sm {
|
|
143
|
+
/* defaults */
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
.editor-btn--md {
|
|
147
|
+
--btn-padding-y: 0;
|
|
148
|
+
--btn-padding-x: var(--spacing-md);
|
|
149
|
+
--btn-font-size: var(--font-size-sm);
|
|
150
|
+
--btn-height: 40px;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
.editor-btn--lg {
|
|
154
|
+
--btn-padding-y: 0;
|
|
155
|
+
--btn-padding-x: var(--spacing-md);
|
|
156
|
+
--btn-font-size: var(--font-size-base);
|
|
157
|
+
--btn-height: 44px;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/* Icon-only: square */
|
|
161
|
+
.editor-btn--icon-only {
|
|
162
|
+
width: var(--btn-height);
|
|
163
|
+
padding: 0;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
.editor-btn--block {
|
|
167
|
+
display: flex;
|
|
168
|
+
width: 100%;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/* Variants — default intent */
|
|
172
|
+
.editor-btn--primary {
|
|
173
|
+
--btn-bg: var(--color-primary);
|
|
174
|
+
--btn-text: var(--color-white);
|
|
175
|
+
--btn-border-color: var(--color-primary);
|
|
176
|
+
--btn-hover-bg: var(--color-primary-hover);
|
|
177
|
+
--btn-hover-text: var(--color-white);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
.editor-btn--secondary {
|
|
181
|
+
--btn-bg: transparent;
|
|
182
|
+
--btn-text: var(--color-primary);
|
|
183
|
+
--btn-border-color: var(--color-primary);
|
|
184
|
+
--btn-hover-bg: var(--color-primary);
|
|
185
|
+
--btn-hover-text: var(--color-white);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
.editor-btn--tertiary {
|
|
189
|
+
--btn-bg: transparent;
|
|
190
|
+
--btn-text: var(--color-text-primary);
|
|
191
|
+
--btn-border-color: var(--color-border);
|
|
192
|
+
--btn-hover-bg: var(--color-surface-hover);
|
|
193
|
+
--btn-hover-text: var(--color-text-primary);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
.editor-btn--transparent {
|
|
197
|
+
--btn-bg: transparent;
|
|
198
|
+
--btn-text: var(--color-text-secondary);
|
|
199
|
+
--btn-border-color: transparent;
|
|
200
|
+
--btn-hover-bg: var(--color-surface-hover);
|
|
201
|
+
--btn-hover-text: var(--color-text-primary);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/* Literal theme inversion: background = the theme's text color and
|
|
205
|
+
vice-versa — high contrast in both light and dark themes without
|
|
206
|
+
borrowing the primary color's semantics. */
|
|
207
|
+
.editor-btn--inverse {
|
|
208
|
+
--btn-bg: var(--color-text-primary);
|
|
209
|
+
--btn-text: var(--color-background);
|
|
210
|
+
--btn-border-color: transparent;
|
|
211
|
+
--btn-hover-bg: var(--color-text-secondary);
|
|
212
|
+
--btn-hover-text: var(--color-background);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/* Destructive intent overrides */
|
|
216
|
+
.editor-btn--destructive.editor-btn--primary {
|
|
217
|
+
--btn-bg: var(--color-danger);
|
|
218
|
+
--btn-text: var(--color-white);
|
|
219
|
+
--btn-border-color: var(--color-danger);
|
|
220
|
+
--btn-hover-bg: var(--color-danger-hover);
|
|
221
|
+
--btn-hover-text: var(--color-white);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
.editor-btn--destructive.editor-btn--secondary {
|
|
225
|
+
--btn-bg: transparent;
|
|
226
|
+
--btn-text: var(--color-danger);
|
|
227
|
+
--btn-border-color: var(--color-danger);
|
|
228
|
+
--btn-hover-bg: var(--color-danger);
|
|
229
|
+
--btn-hover-text: var(--color-white);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
.editor-btn--destructive.editor-btn--tertiary,
|
|
233
|
+
.editor-btn--destructive.editor-btn--transparent {
|
|
234
|
+
--btn-text: var(--color-danger);
|
|
235
|
+
--btn-hover-bg: var(--color-danger-soft);
|
|
236
|
+
--btn-hover-text: var(--color-danger);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/* Disabled: gray-out regardless of variant. Placed after the variants so the
|
|
240
|
+
CSS var overrides win the cascade (same specificity, last declaration). */
|
|
241
|
+
.editor-btn:disabled {
|
|
242
|
+
--btn-bg: var(--color-surface);
|
|
243
|
+
--btn-text: var(--color-text-muted);
|
|
244
|
+
--btn-border-color: var(--color-border);
|
|
245
|
+
--btn-hover-bg: var(--color-surface);
|
|
246
|
+
--btn-hover-text: var(--color-text-muted);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/* The transparent variant has no chrome when enabled — disabling it must not
|
|
250
|
+
ADD a box; the muted text alone carries the disabled signal. */
|
|
251
|
+
.editor-btn--transparent:disabled {
|
|
252
|
+
--btn-bg: transparent;
|
|
253
|
+
--btn-border-color: transparent;
|
|
254
|
+
--btn-hover-bg: transparent;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/* Inverse stays a filled, borderless chip when disabled — dimmed, but the
|
|
258
|
+
silhouette must not change. */
|
|
259
|
+
.editor-btn--inverse:disabled {
|
|
260
|
+
--btn-bg: var(--color-surface-hover);
|
|
261
|
+
--btn-border-color: transparent;
|
|
262
|
+
--btn-hover-bg: var(--color-surface-hover);
|
|
263
|
+
}
|
|
264
|
+
</style>
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, watch } from "vue"
|
|
3
|
+
import CopyButton from "./CopyButton.vue"
|
|
4
|
+
import { useI18n } from "@linto-ai/transcript-ui-i18n"
|
|
5
|
+
|
|
6
|
+
const props = defineProps<{
|
|
7
|
+
code: string
|
|
8
|
+
lang?: string
|
|
9
|
+
streaming?: boolean
|
|
10
|
+
}>()
|
|
11
|
+
|
|
12
|
+
const { t } = useI18n()
|
|
13
|
+
|
|
14
|
+
function copy() {
|
|
15
|
+
return navigator.clipboard.writeText(props.code)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Highlighted token markup, or null to render plain text (streaming, unknown
|
|
19
|
+
// language, or before the lazy highlighter chunk has loaded).
|
|
20
|
+
const highlighted = ref<string | null>(null)
|
|
21
|
+
let seq = 0
|
|
22
|
+
|
|
23
|
+
watch(
|
|
24
|
+
() => [props.code, props.lang, props.streaming] as const,
|
|
25
|
+
async ([code, lang, streaming]) => {
|
|
26
|
+
const run = ++seq
|
|
27
|
+
if (streaming || !code) {
|
|
28
|
+
highlighted.value = null
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
const { highlightCode } = await import("../utils/highlight")
|
|
32
|
+
// Drop the result if another change superseded this run while awaiting.
|
|
33
|
+
if (run === seq) highlighted.value = highlightCode(code, lang ?? "")
|
|
34
|
+
},
|
|
35
|
+
{ immediate: true },
|
|
36
|
+
)
|
|
37
|
+
</script>
|
|
38
|
+
|
|
39
|
+
<template>
|
|
40
|
+
<div class="code-block">
|
|
41
|
+
<CopyButton
|
|
42
|
+
v-if="!streaming"
|
|
43
|
+
class="code-block__copy"
|
|
44
|
+
variant="transparent"
|
|
45
|
+
size="sm"
|
|
46
|
+
:copy-fn="copy"
|
|
47
|
+
:aria-label="t('markdown.copyCode')" />
|
|
48
|
+
<pre><code v-if="highlighted" v-html="highlighted" /><code v-else>{{ code }}</code></pre>
|
|
49
|
+
</div>
|
|
50
|
+
</template>
|
|
51
|
+
|
|
52
|
+
<style scoped>
|
|
53
|
+
.code-block {
|
|
54
|
+
position: relative;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.code-block__copy {
|
|
58
|
+
position: absolute;
|
|
59
|
+
top: var(--spacing-xs);
|
|
60
|
+
right: var(--spacing-xs);
|
|
61
|
+
opacity: 0;
|
|
62
|
+
transition: opacity 0.15s ease;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.code-block:hover .code-block__copy,
|
|
66
|
+
.code-block:focus-within .code-block__copy {
|
|
67
|
+
opacity: 1;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.code-block pre {
|
|
71
|
+
margin: var(--spacing-md) 0;
|
|
72
|
+
padding: var(--spacing-md);
|
|
73
|
+
background-color: var(--color-surface);
|
|
74
|
+
border-radius: var(--radius-md);
|
|
75
|
+
overflow-x: auto;
|
|
76
|
+
border: 1px solid var(--color-border);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.code-block pre code {
|
|
80
|
+
padding: 0;
|
|
81
|
+
background: none;
|
|
82
|
+
font-family: var(--font-family-mono);
|
|
83
|
+
font-size: 0.9em;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
@media (prefers-reduced-motion: reduce) {
|
|
87
|
+
.code-block__copy {
|
|
88
|
+
transition: none;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
</style>
|
|
92
|
+
|
|
93
|
+
<!-- Not scoped: Prism generates plain, unscoped `.token.*` spans via
|
|
94
|
+
innerHTML, so a scoped style's data-v-xxx attribute selector would never
|
|
95
|
+
match them. -->
|
|
96
|
+
<style>
|
|
97
|
+
@import "prismjs/themes/prism.css";
|
|
98
|
+
</style>
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, ref } from "vue"
|
|
3
|
+
import Button from "./Button.vue"
|
|
4
|
+
import EditorIcon from "./EditorIcon.vue"
|
|
5
|
+
import { ICON_SIZES } from "./icons"
|
|
6
|
+
|
|
7
|
+
const props = withDefaults(
|
|
8
|
+
defineProps<{
|
|
9
|
+
icon?: "copy" | "clipboard-list" | "clipboard-type"
|
|
10
|
+
copyFn: () => Promise<void>
|
|
11
|
+
variant?: "primary" | "secondary" | "tertiary" | "transparent"
|
|
12
|
+
size?: "sm" | "md" | "lg"
|
|
13
|
+
disabled?: boolean
|
|
14
|
+
block?: boolean
|
|
15
|
+
ariaLabel?: string
|
|
16
|
+
}>(),
|
|
17
|
+
{
|
|
18
|
+
icon: "copy",
|
|
19
|
+
},
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
const copied = ref(false)
|
|
23
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
24
|
+
|
|
25
|
+
async function onClick() {
|
|
26
|
+
if (copied.value) return
|
|
27
|
+
try {
|
|
28
|
+
await props.copyFn()
|
|
29
|
+
copied.value = true
|
|
30
|
+
timer = setTimeout(() => {
|
|
31
|
+
copied.value = false
|
|
32
|
+
}, 2000)
|
|
33
|
+
} catch (e) {
|
|
34
|
+
console.error(e)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
defineExpose({
|
|
39
|
+
reset: () => {
|
|
40
|
+
copied.value = false
|
|
41
|
+
clearTimeout(timer)
|
|
42
|
+
},
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
const currentIconName = computed(() => (copied.value ? "check" : props.icon))
|
|
46
|
+
const iconSize = computed(() => ICON_SIZES[props.size ?? "sm"])
|
|
47
|
+
</script>
|
|
48
|
+
|
|
49
|
+
<template>
|
|
50
|
+
<Button
|
|
51
|
+
:variant="variant"
|
|
52
|
+
:size="size"
|
|
53
|
+
:disabled="disabled"
|
|
54
|
+
:block="block"
|
|
55
|
+
:aria-label="ariaLabel"
|
|
56
|
+
:class="{ 'copy-btn--copied': copied }"
|
|
57
|
+
@click="onClick">
|
|
58
|
+
<template #icon>
|
|
59
|
+
<Transition name="copy-icon" mode="out-in">
|
|
60
|
+
<EditorIcon
|
|
61
|
+
:key="currentIconName"
|
|
62
|
+
:name="currentIconName"
|
|
63
|
+
:size="iconSize" />
|
|
64
|
+
</Transition>
|
|
65
|
+
</template>
|
|
66
|
+
<slot />
|
|
67
|
+
</Button>
|
|
68
|
+
</template>
|
|
69
|
+
|
|
70
|
+
<style scoped>
|
|
71
|
+
.copy-btn--copied {
|
|
72
|
+
color: var(--color-success, #2e7d32);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
.copy-icon-enter-active,
|
|
76
|
+
.copy-icon-leave-active {
|
|
77
|
+
transition:
|
|
78
|
+
opacity var(--transition-duration) ease,
|
|
79
|
+
scale var(--transition-duration) ease;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.copy-icon-enter-from {
|
|
83
|
+
opacity: 0;
|
|
84
|
+
scale: 0.6;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.copy-icon-leave-to {
|
|
88
|
+
opacity: 0;
|
|
89
|
+
scale: 0.6;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
@media (prefers-reduced-motion: reduce) {
|
|
93
|
+
.copy-icon-enter-active,
|
|
94
|
+
.copy-icon-leave-active {
|
|
95
|
+
transition: none;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
</style>
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, watch, nextTick, useTemplateRef, computed } from "vue"
|
|
3
|
+
import FormInput, { type FormField } from "../molecules/FormInput.vue"
|
|
4
|
+
|
|
5
|
+
const props = withDefaults(
|
|
6
|
+
defineProps<{
|
|
7
|
+
modelValue: string
|
|
8
|
+
disabled?: boolean
|
|
9
|
+
placeholder?: string
|
|
10
|
+
ariaLabel?: string
|
|
11
|
+
}>(),
|
|
12
|
+
{ disabled: false },
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
const emit = defineEmits<{
|
|
16
|
+
"update:modelValue": [value: string]
|
|
17
|
+
commit: [value: string]
|
|
18
|
+
cancel: []
|
|
19
|
+
}>()
|
|
20
|
+
|
|
21
|
+
const isEditing = ref(false)
|
|
22
|
+
const draft = ref(props.modelValue)
|
|
23
|
+
const inputRef = useTemplateRef<{ focus: () => void; select: () => void }>("input")
|
|
24
|
+
|
|
25
|
+
const field = computed<FormField>(() => ({
|
|
26
|
+
placeholder: props.placeholder,
|
|
27
|
+
customParams: props.ariaLabel ? { "aria-label": props.ariaLabel } : undefined,
|
|
28
|
+
}))
|
|
29
|
+
|
|
30
|
+
watch(
|
|
31
|
+
() => props.modelValue,
|
|
32
|
+
(v) => {
|
|
33
|
+
if (!isEditing.value) draft.value = v
|
|
34
|
+
},
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
async function startEdit(): Promise<void> {
|
|
38
|
+
if (props.disabled) return
|
|
39
|
+
draft.value = props.modelValue
|
|
40
|
+
isEditing.value = true
|
|
41
|
+
await nextTick()
|
|
42
|
+
inputRef.value?.focus()
|
|
43
|
+
inputRef.value?.select()
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function commit(): void {
|
|
47
|
+
if (!isEditing.value) return
|
|
48
|
+
const trimmed = draft.value.trim()
|
|
49
|
+
isEditing.value = false
|
|
50
|
+
if (!trimmed || trimmed === props.modelValue) return
|
|
51
|
+
emit("update:modelValue", trimmed)
|
|
52
|
+
emit("commit", trimmed)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function cancel(): void {
|
|
56
|
+
if (!isEditing.value) return
|
|
57
|
+
isEditing.value = false
|
|
58
|
+
draft.value = props.modelValue
|
|
59
|
+
emit("cancel")
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function onKeydown(e: KeyboardEvent): void {
|
|
63
|
+
if (e.key === "Enter") {
|
|
64
|
+
e.preventDefault()
|
|
65
|
+
commit()
|
|
66
|
+
} else if (e.key === "Escape") {
|
|
67
|
+
e.preventDefault()
|
|
68
|
+
cancel()
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
</script>
|
|
72
|
+
|
|
73
|
+
<template>
|
|
74
|
+
<FormInput
|
|
75
|
+
v-if="isEditing"
|
|
76
|
+
ref="input"
|
|
77
|
+
v-model="draft"
|
|
78
|
+
:field="field"
|
|
79
|
+
size="sm"
|
|
80
|
+
full-width
|
|
81
|
+
@keydown="onKeydown"
|
|
82
|
+
@blur="commit" />
|
|
83
|
+
<button
|
|
84
|
+
v-else
|
|
85
|
+
type="button"
|
|
86
|
+
class="editable-text-display"
|
|
87
|
+
:disabled="disabled"
|
|
88
|
+
:aria-label="ariaLabel"
|
|
89
|
+
@click="startEdit">
|
|
90
|
+
{{ modelValue || placeholder }}
|
|
91
|
+
</button>
|
|
92
|
+
</template>
|
|
93
|
+
|
|
94
|
+
<style scoped>
|
|
95
|
+
.editable-text-display {
|
|
96
|
+
all: unset;
|
|
97
|
+
cursor: text;
|
|
98
|
+
text-align: left;
|
|
99
|
+
font: inherit;
|
|
100
|
+
color: inherit;
|
|
101
|
+
line-height: inherit;
|
|
102
|
+
padding: 0;
|
|
103
|
+
border: 1px solid transparent;
|
|
104
|
+
border-radius: var(--radius-sm);
|
|
105
|
+
min-width: 0;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.editable-text-display:not(:disabled):hover {
|
|
109
|
+
border-color: var(--color-border);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.editable-text-display:disabled {
|
|
113
|
+
cursor: default;
|
|
114
|
+
}
|
|
115
|
+
</style>
|