mirror of
https://github.com/ecomfe/vue-echarts.git
synced 2025-10-28 03:25:02 +08:00
feat: revamp demo
This commit is contained in:
585
demo/CodeGen.vue
585
demo/CodeGen.vue
@ -1,92 +1,33 @@
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ref,
|
||||
computed,
|
||||
watch,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
onBeforeUnmount,
|
||||
nextTick,
|
||||
} from "vue";
|
||||
import { useLocalStorage } from "@vueuse/core";
|
||||
import "highlight.js/styles/github.css";
|
||||
import hljs from "highlight.js/lib/core";
|
||||
import javascript from "highlight.js/lib/languages/javascript";
|
||||
import typescript from "highlight.js/lib/languages/typescript";
|
||||
import hljsVuePlugin from "@highlightjs/vue-plugin";
|
||||
import { initialize, transform } from "esbuild-wasm";
|
||||
import wasmURL from "esbuild-wasm/esbuild.wasm?url";
|
||||
import { useLocalStorage, useTimeoutFn } from "@vueuse/core";
|
||||
import { track } from "@vercel/analytics";
|
||||
|
||||
import { getImportsFromOption } from "./utils/codegen";
|
||||
import {
|
||||
getImportsFromOption,
|
||||
type Quote,
|
||||
type PublicCodegenOptions,
|
||||
} from "./utils/codegen";
|
||||
import {
|
||||
createOptionEditor,
|
||||
createCodeViewer,
|
||||
type OptionEditor,
|
||||
type CodeViewer,
|
||||
} from "./services/monaco";
|
||||
import {
|
||||
useOptionAnalysis,
|
||||
type AnalyzerIssue,
|
||||
} from "./composables/useOptionAnalysis";
|
||||
import { useDemoDark } from "./composables/useDemoDark";
|
||||
|
||||
hljs.registerLanguage("javascript", javascript);
|
||||
hljs.registerLanguage("typescript", typescript);
|
||||
const CodeHighlight = hljsVuePlugin.component;
|
||||
|
||||
const codegenOptions = useLocalStorage("ve.codegenOptions", {
|
||||
indent: " ",
|
||||
quote: "'",
|
||||
multiline: false,
|
||||
maxLen: 80,
|
||||
semi: false,
|
||||
includeType: false,
|
||||
});
|
||||
|
||||
const props = defineProps({ open: Boolean, renderer: String });
|
||||
const emit = defineEmits(["update:open"]);
|
||||
|
||||
const dialog = ref(null);
|
||||
let clickFrom = null;
|
||||
|
||||
function onMousedown(e) {
|
||||
clickFrom = e.target;
|
||||
}
|
||||
|
||||
function closeFromOutside() {
|
||||
if (dialog.value?.contains(clickFrom)) {
|
||||
return;
|
||||
}
|
||||
close();
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit("update:open", false);
|
||||
}
|
||||
|
||||
const renderer = ref(props.renderer);
|
||||
const source = ref(null);
|
||||
watch(
|
||||
() => props.open,
|
||||
async (val) => {
|
||||
if (val) {
|
||||
renderer.value = props.renderer;
|
||||
}
|
||||
|
||||
await nextTick();
|
||||
|
||||
if (initializing.value) {
|
||||
return;
|
||||
}
|
||||
source.value?.focus();
|
||||
},
|
||||
);
|
||||
|
||||
const copied = ref(false);
|
||||
const initializing = ref(true);
|
||||
const optionCode = ref("");
|
||||
const transformedCode = ref("");
|
||||
const transformErrors = ref([]);
|
||||
|
||||
onMounted(async () => {
|
||||
// prevent multiple initializations during HMR
|
||||
if (!window.__esbuildInitialized) {
|
||||
await initialize({ wasmURL });
|
||||
window.__esbuildInitialized = true;
|
||||
}
|
||||
|
||||
initializing.value = false;
|
||||
|
||||
optionCode.value = `{
|
||||
const DEFAULT_OPTION = `{
|
||||
title: {
|
||||
text: 'Referer of a Website',
|
||||
subtext: 'Fake Data',
|
||||
@ -122,94 +63,281 @@ onMounted(async () => {
|
||||
]
|
||||
}`;
|
||||
|
||||
await nextTick();
|
||||
|
||||
source.value?.focus();
|
||||
});
|
||||
|
||||
watch(optionCode, async (val) => {
|
||||
try {
|
||||
transformedCode.value = await transform(`(${val})`, { loader: "ts" });
|
||||
transformErrors.value = [];
|
||||
} catch (e) {
|
||||
transformErrors.value = e.errors;
|
||||
}
|
||||
});
|
||||
|
||||
function formatError(errors) {
|
||||
return errors
|
||||
.map(({ text, location: { lineText, line, column, length } }) => {
|
||||
const digit = Math.ceil(Math.log10(line)) || 1;
|
||||
lineText = line === 1 ? lineText.slice(1) : lineText;
|
||||
lineText =
|
||||
line === optionCode.value.split("\n").length
|
||||
? lineText.slice(0, -1)
|
||||
: lineText;
|
||||
column = line === 1 ? column - 1 : column;
|
||||
|
||||
return `/* ${text} */
|
||||
|
||||
// ${line} | ${lineText}
|
||||
// ${" ".repeat(digit)} | ${" ".repeat(column)}${"~".repeat(length)}
|
||||
`;
|
||||
})
|
||||
.join("\n\n");
|
||||
interface CodegenPreferences {
|
||||
indent: string;
|
||||
quote: Quote;
|
||||
multiline: boolean;
|
||||
maxLen: number;
|
||||
semi: boolean;
|
||||
includeType: boolean;
|
||||
renderer: Renderer;
|
||||
}
|
||||
|
||||
const importCode = computed(() => {
|
||||
if (optionCode.value.trim() === "") {
|
||||
return "// Paste your option code first";
|
||||
}
|
||||
const codegenOptions = useLocalStorage<CodegenPreferences>(
|
||||
"ve.codegenOptions",
|
||||
{
|
||||
indent: " ",
|
||||
quote: "'",
|
||||
multiline: false,
|
||||
maxLen: 80,
|
||||
semi: false,
|
||||
includeType: false,
|
||||
renderer: "canvas",
|
||||
} satisfies CodegenPreferences,
|
||||
);
|
||||
|
||||
if (transformErrors.value.length) {
|
||||
return formatError(transformErrors.value);
|
||||
}
|
||||
const props = defineProps<{ open: boolean; renderer: string }>();
|
||||
const emit = defineEmits<{ "update:open": [boolean] }>();
|
||||
|
||||
try {
|
||||
return getImportsFromOption(eval(transformedCode.value.code), {
|
||||
renderer: renderer.value,
|
||||
...codegenOptions.value,
|
||||
});
|
||||
} catch (e) {
|
||||
return `/* Invalid ECharts option */
|
||||
const {
|
||||
code: sourceCode,
|
||||
state: analysisState,
|
||||
updateSource,
|
||||
dispose: disposeAnalysis,
|
||||
} = useOptionAnalysis(DEFAULT_OPTION);
|
||||
|
||||
// ${e.message}
|
||||
`;
|
||||
}
|
||||
const dialog = ref<HTMLElement | null>(null);
|
||||
let clickFrom: Node | null = null;
|
||||
|
||||
const editorEl = ref<HTMLElement | null>(null);
|
||||
const outputEl = ref<HTMLElement | null>(null);
|
||||
let optionEditor: OptionEditor | null = null;
|
||||
let importViewer: CodeViewer | null = null;
|
||||
let suppressNextEditorEvent = false;
|
||||
const initializing = ref<boolean>(true);
|
||||
const showAnalyzingOverlay = ref(false);
|
||||
const { start: scheduleAnalyzingOverlay, stop: cancelAnalyzingOverlay } =
|
||||
useTimeoutFn(() => {
|
||||
showAnalyzingOverlay.value = true;
|
||||
}, 180);
|
||||
|
||||
type Renderer = "canvas" | "svg";
|
||||
|
||||
const renderer = ref<Renderer>(props.renderer === "svg" ? "svg" : "canvas");
|
||||
codegenOptions.value.renderer = renderer.value;
|
||||
|
||||
const isDark = useDemoDark();
|
||||
const monacoTheme = computed(() => (isDark.value ? "vs-dark" : "vs"));
|
||||
|
||||
watch(
|
||||
() => props.renderer,
|
||||
(value) => {
|
||||
if (props.open) {
|
||||
renderer.value = value === "svg" ? "svg" : "canvas";
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
watch(renderer, (value) => {
|
||||
codegenOptions.value.renderer = value;
|
||||
});
|
||||
|
||||
watch(importCode, () => {
|
||||
copied.value = false;
|
||||
});
|
||||
function onMousedown(event: MouseEvent) {
|
||||
clickFrom = event.target instanceof Node ? event.target : null;
|
||||
}
|
||||
|
||||
// copy message
|
||||
const messageOpen = ref(false);
|
||||
let messageTimer;
|
||||
|
||||
function trackCopy(from) {
|
||||
if (copied.value) {
|
||||
// only track copy after modifications
|
||||
function closeFromOutside() {
|
||||
const target = clickFrom;
|
||||
if (target && dialog.value?.contains(target)) {
|
||||
return;
|
||||
}
|
||||
close();
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit("update:open", false);
|
||||
}
|
||||
|
||||
const copied = ref(false);
|
||||
const messageOpen = ref(false);
|
||||
const { start: scheduleMessageClose, stop: cancelMessageClose } = useTimeoutFn(
|
||||
() => {
|
||||
messageOpen.value = false;
|
||||
},
|
||||
2018,
|
||||
);
|
||||
|
||||
function trackCopy(from: "button" | "system") {
|
||||
if (copied.value) {
|
||||
return;
|
||||
}
|
||||
copied.value = true;
|
||||
track("copy-code", { from });
|
||||
}
|
||||
|
||||
function copy() {
|
||||
trackCopy("button");
|
||||
clearTimeout(messageTimer);
|
||||
|
||||
navigator.clipboard.writeText(importCode.value);
|
||||
messageOpen.value = true;
|
||||
|
||||
messageTimer = setTimeout(() => {
|
||||
messageOpen.value = false;
|
||||
}, 2018);
|
||||
function formatIssues(issues: readonly AnalyzerIssue[]) {
|
||||
if (!issues.length) {
|
||||
return "";
|
||||
}
|
||||
return issues
|
||||
.map((issue) => {
|
||||
const lines = [`/* ${issue.message} */`];
|
||||
if (issue.hint) {
|
||||
lines.push(`// Hint: ${issue.hint}`);
|
||||
}
|
||||
if (issue.range) {
|
||||
const pointer = `${issue.range.startLineNumber}:${issue.range.startColumn}`;
|
||||
lines.push(`// ${pointer}`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
})
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
const hasErrors = computed<boolean>(() => analysisState.hasBlockingIssue);
|
||||
|
||||
const isBusy = computed<boolean>(
|
||||
() => initializing.value || showAnalyzingOverlay.value,
|
||||
);
|
||||
|
||||
const importCode = computed(() => {
|
||||
const raw = sourceCode.value.trim();
|
||||
if (!raw) {
|
||||
return "// Paste your option code first";
|
||||
}
|
||||
|
||||
if (hasErrors.value) {
|
||||
const blockingIssues = analysisState.issues.filter(
|
||||
(issue) => issue.severity === "error",
|
||||
);
|
||||
return formatIssues(blockingIssues);
|
||||
}
|
||||
|
||||
if (!analysisState.option) {
|
||||
return "// Option analysis did not produce a result";
|
||||
}
|
||||
|
||||
try {
|
||||
const preferences = codegenOptions.value;
|
||||
const config: PublicCodegenOptions = {
|
||||
indent: preferences.indent,
|
||||
quote: preferences.quote,
|
||||
multiline: preferences.multiline,
|
||||
maxLen: preferences.maxLen,
|
||||
semi: preferences.semi,
|
||||
includeType: preferences.includeType,
|
||||
renderer: renderer.value,
|
||||
};
|
||||
return getImportsFromOption(analysisState.option, config);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : String(error ?? "Unknown error");
|
||||
return `/* Invalid ECharts option */\n\n// ${message}`;
|
||||
}
|
||||
});
|
||||
|
||||
watch(importCode, (value) => {
|
||||
importViewer?.setValue(value);
|
||||
copied.value = false;
|
||||
});
|
||||
|
||||
function copy() {
|
||||
if (!navigator.clipboard) {
|
||||
return;
|
||||
}
|
||||
trackCopy("button");
|
||||
navigator.clipboard.writeText(importCode.value);
|
||||
messageOpen.value = true;
|
||||
cancelMessageClose();
|
||||
scheduleMessageClose();
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick();
|
||||
if (editorEl.value) {
|
||||
optionEditor = createOptionEditor(editorEl.value, {
|
||||
initialCode: sourceCode.value,
|
||||
theme: monacoTheme.value,
|
||||
onChange(value) {
|
||||
if (suppressNextEditorEvent) {
|
||||
return;
|
||||
}
|
||||
updateSource(value);
|
||||
},
|
||||
});
|
||||
optionEditor.setMarkers(analysisState.diagnostics);
|
||||
}
|
||||
if (outputEl.value) {
|
||||
importViewer = createCodeViewer(outputEl.value, {
|
||||
initialCode: importCode.value,
|
||||
language: codegenOptions.value.includeType ? "typescript" : "javascript",
|
||||
theme: monacoTheme.value,
|
||||
});
|
||||
}
|
||||
initializing.value = false;
|
||||
});
|
||||
|
||||
watch(monacoTheme, (theme) => {
|
||||
optionEditor?.setTheme(theme);
|
||||
importViewer?.setTheme(theme);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => codegenOptions.value.includeType,
|
||||
(includeType) => {
|
||||
importViewer?.setLanguage(includeType ? "typescript" : "javascript");
|
||||
importViewer?.setValue(importCode.value);
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => analysisState.status,
|
||||
(status) => {
|
||||
if (status === "analyzing" && !initializing.value) {
|
||||
scheduleAnalyzingOverlay();
|
||||
return;
|
||||
}
|
||||
cancelAnalyzingOverlay();
|
||||
showAnalyzingOverlay.value = false;
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
async (value) => {
|
||||
if (value) {
|
||||
renderer.value = props.renderer === "svg" ? "svg" : "canvas";
|
||||
await nextTick();
|
||||
optionEditor?.editor.focus();
|
||||
optionEditor?.editor.layout();
|
||||
importViewer?.editor.layout();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => analysisState.diagnostics,
|
||||
(diagnostics) => {
|
||||
optionEditor?.setMarkers(diagnostics);
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
watch(sourceCode, (value) => {
|
||||
if (!optionEditor) {
|
||||
return;
|
||||
}
|
||||
const current = optionEditor.getValue();
|
||||
if (current === value) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
suppressNextEditorEvent = true;
|
||||
optionEditor.setValue(value);
|
||||
} finally {
|
||||
suppressNextEditorEvent = false;
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearTimeout(messageTimer);
|
||||
cancelMessageClose();
|
||||
cancelAnalyzingOverlay();
|
||||
optionEditor?.dispose();
|
||||
optionEditor = null;
|
||||
importViewer?.dispose();
|
||||
importViewer = null;
|
||||
disposeAnalysis();
|
||||
});
|
||||
</script>
|
||||
|
||||
@ -221,8 +349,14 @@ onBeforeUnmount(() => {
|
||||
@click="closeFromOutside"
|
||||
@keydown.esc="close"
|
||||
>
|
||||
<section class="dialog" ref="dialog">
|
||||
<h2>✨ <code>import</code> code generator</h2>
|
||||
<section
|
||||
ref="dialog"
|
||||
class="dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="codegen-title"
|
||||
>
|
||||
<h2 id="codegen-title">Generate import code</h2>
|
||||
<section class="options">
|
||||
<label>
|
||||
Renderer
|
||||
@ -233,64 +367,59 @@ onBeforeUnmount(() => {
|
||||
</label>
|
||||
<label>
|
||||
TypeScript
|
||||
<input type="checkbox" v-model="codegenOptions.includeType" />
|
||||
<input v-model="codegenOptions.includeType" type="checkbox" />
|
||||
</label>
|
||||
<label>
|
||||
Multiline
|
||||
<input type="checkbox" v-model="codegenOptions.multiline" />
|
||||
<input v-model="codegenOptions.multiline" type="checkbox" />
|
||||
</label>
|
||||
<label>
|
||||
Semi
|
||||
<input type="checkbox" v-model="codegenOptions.semi" />
|
||||
<input v-model="codegenOptions.semi" type="checkbox" />
|
||||
</label>
|
||||
<label>
|
||||
Quote
|
||||
<select v-model="codegenOptions.quote">
|
||||
<option value="'">single</option>
|
||||
<option value='"'>double</option>
|
||||
<option value="'">Single</option>
|
||||
<option value='"'>Double</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Indent
|
||||
<select v-model="codegenOptions.indent">
|
||||
<option value=" ">2</option>
|
||||
<option value=" ">4</option>
|
||||
<option value=" ">2 spaces</option>
|
||||
<option value=" ">4 spaces</option>
|
||||
<option value=" ">Tab</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Max length
|
||||
<input
|
||||
v-model.number="codegenOptions.maxLen"
|
||||
type="number"
|
||||
step="10"
|
||||
v-model.number="codegenOptions.maxLen"
|
||||
/>
|
||||
</label>
|
||||
</section>
|
||||
<section class="code">
|
||||
<textarea
|
||||
ref="source"
|
||||
<div
|
||||
ref="editorEl"
|
||||
class="option-code"
|
||||
v-model="optionCode"
|
||||
:placeholder="
|
||||
initializing
|
||||
? 'Initializing...'
|
||||
: 'Paste your option code (TS/JS literal) here...'
|
||||
"
|
||||
:disabled="initializing"
|
||||
autofocus
|
||||
spellcheck="false"
|
||||
></textarea>
|
||||
<div class="import-code" @copy="trackCopy('system')">
|
||||
<code-highlight
|
||||
:language="codegenOptions.includeType ? 'typescript' : 'javascript'"
|
||||
:code="importCode"
|
||||
/>
|
||||
</div>
|
||||
aria-label="ECharts option (TS/JS literal)"
|
||||
:aria-busy="isBusy"
|
||||
></div>
|
||||
<div
|
||||
ref="outputEl"
|
||||
class="import-code"
|
||||
role="textbox"
|
||||
aria-label="Generated import code"
|
||||
aria-readonly="true"
|
||||
@copy="trackCopy('system')"
|
||||
></div>
|
||||
<button
|
||||
class="copy"
|
||||
:disabled="analysisState.hasBlockingIssue"
|
||||
@click="copy"
|
||||
:disabled="importCode.startsWith('/*') || importCode.startsWith('//')"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
@ -298,37 +427,31 @@ onBeforeUnmount(() => {
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<aside class="message" :class="{ open: messageOpen }">
|
||||
<aside
|
||||
class="message"
|
||||
:class="{ open: messageOpen }"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
Copied to clipboard
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
input::-webkit-outer-spin-button,
|
||||
input::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Works for Firefox */
|
||||
input[type="number"] {
|
||||
appearance: textfield;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="number"] {
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.dialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 80vw;
|
||||
height: 90vh;
|
||||
border-radius: 6px;
|
||||
border-radius: var(--r-l);
|
||||
overflow: hidden;
|
||||
background-color: #fff;
|
||||
box-shadow: 0 0 45px rgba(0, 0, 0, 0.2);
|
||||
background-color: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.dialog h2 {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.options {
|
||||
@ -337,22 +460,23 @@ input[type="number"] {
|
||||
align-items: stretch;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-bottom: 1px solid var(--border);
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
input,
|
||||
button,
|
||||
select {
|
||||
height: 2.4em;
|
||||
gap: 6px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
input[type="number"] {
|
||||
width: 60px;
|
||||
width: 54px;
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
}
|
||||
|
||||
@ -360,6 +484,7 @@ input[type="number"] {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
text-align: left;
|
||||
align-items: stretch;
|
||||
flex-grow: 1;
|
||||
min-height: 0;
|
||||
@ -370,33 +495,19 @@ input[type="number"] {
|
||||
flex: 0 0 50%;
|
||||
margin: 0;
|
||||
border: none;
|
||||
line-height: 1.2;
|
||||
font-size: 13px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.import-code {
|
||||
border-left: 1px solid rgba(0, 0, 0, 0.1);
|
||||
|
||||
pre {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: #fff;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
code {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.option-code {
|
||||
padding: 1em;
|
||||
outline: none;
|
||||
resize: none;
|
||||
.option-code[aria-busy="true"]::after {
|
||||
content: "Analyzing...";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--surface);
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@ -404,6 +515,8 @@ input[type="number"] {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 10px;
|
||||
border-radius: var(--r-m);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.message {
|
||||
@ -412,12 +525,12 @@ input[type="number"] {
|
||||
bottom: 2rem;
|
||||
left: 50%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background-color: rgba(45, 52, 64, 0.98);
|
||||
box-shadow: 0 4px 16px rgba(45, 52, 64, 0.6);
|
||||
color: #fff;
|
||||
background-color: var(--text);
|
||||
box-shadow: var(--shadow);
|
||||
color: var(--surface);
|
||||
font-size: 0.875rem;
|
||||
transform: translate(-50%, 200%);
|
||||
border-radius: 4px;
|
||||
border-radius: var(--r-s);
|
||||
opacity: 0;
|
||||
transition:
|
||||
transform 0.2s,
|
||||
|
||||
Reference in New Issue
Block a user