feat(ui): adopt Tailwind v4 design system and archive change
- Add tailwindcss v4 + @tailwindcss/vite to mall, shop-admin, admin - Add @vmall/shared/theme.css tokens with html[data-accent] presets - Add @vmall/ui kit (VBtn/VBadge/VField/VInput/VCard/VPanel/VTable/VPage, VAccentSwatch, useAccent) as a Nuxt module - Convert all three apps to kit + utilities; delete ui.css/mall.css and every <style scoped>; consoles get accent presets, mall locked to red - Fix VCard boolean prop default (padding) and PDP/store stale useAsyncData keys on param navigation - Archive adopt-tailwind-design-system; new frontend-ui capability spec
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "@vmall/ui",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./nuxt": "./src/nuxt.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nuxt/kit": "^3.15.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.5.13"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { ACCENTS, useAccent, type Accent } from "../composables/useAccent";
|
||||
|
||||
const { accent, setAccent } = useAccent();
|
||||
|
||||
const swatches: Record<Accent, string> = {
|
||||
red: "#ca151e",
|
||||
blue: "#2f6fed",
|
||||
teal: "#0f8a72",
|
||||
violet: "#7c3aed",
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center gap-1.5" role="radiogroup" aria-label="Accent preset">
|
||||
<button
|
||||
v-for="name in ACCENTS"
|
||||
:key="name"
|
||||
type="button"
|
||||
role="radio"
|
||||
:aria-checked="accent === name"
|
||||
:title="name"
|
||||
:style="{ backgroundColor: swatches[name] }"
|
||||
:class="[
|
||||
'h-5 w-5 cursor-pointer rounded-full border-2 transition-transform hover:scale-110',
|
||||
accent === name ? 'border-text' : 'border-transparent',
|
||||
]"
|
||||
@click="setAccent(name)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
const props = withDefaults(
|
||||
defineProps<{ tone?: "green" | "blue" | "orange" | "red" | "gray" }>(),
|
||||
{ tone: "gray" },
|
||||
);
|
||||
|
||||
const tones = {
|
||||
green: "bg-success/10 text-success",
|
||||
blue: "bg-primary-soft text-primary",
|
||||
orange: "bg-warning/10 text-warning",
|
||||
red: "bg-danger/10 text-danger",
|
||||
gray: "bg-bg text-muted",
|
||||
} as const;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
:class="[
|
||||
'inline-block rounded px-2 py-0.5 text-xs font-medium',
|
||||
tones[props.tone],
|
||||
]"
|
||||
>
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
variant?: "default" | "primary" | "danger";
|
||||
size?: "md" | "sm";
|
||||
type?: "button" | "submit" | "reset";
|
||||
disabled?: boolean;
|
||||
}>(),
|
||||
{ variant: "default", size: "md", type: "button", disabled: false },
|
||||
);
|
||||
|
||||
const base =
|
||||
"inline-flex cursor-pointer items-center justify-center gap-1 rounded-md border text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50";
|
||||
const variants = {
|
||||
default: "border-border bg-surface text-text hover:bg-bg",
|
||||
primary: "border-primary bg-primary text-white hover:bg-primary-hover",
|
||||
danger: "border-danger text-danger hover:bg-danger/10",
|
||||
} as const;
|
||||
const sizes = { md: "px-4 py-2", sm: "px-2.5 py-1 text-[13px]" } as const;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
:type="props.type"
|
||||
:disabled="props.disabled"
|
||||
:class="[base, variants[props.variant], sizes[props.size]]"
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
const props = withDefaults(defineProps<{ padded?: boolean }>(), { padded: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
'rounded-lg border border-border bg-surface shadow-sm',
|
||||
props.padded ? 'p-5' : '',
|
||||
]"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ label?: string; error?: string }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mb-3.5">
|
||||
<label v-if="label" class="mb-1 block text-sm font-medium text-text">{{ label }}</label>
|
||||
<slot />
|
||||
<p v-if="error" class="mt-1 text-sm text-danger">{{ error }}</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{ type?: string; placeholder?: string; disabled?: boolean }>(),
|
||||
{ type: "text", disabled: false },
|
||||
);
|
||||
const model = defineModel<string | number>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input
|
||||
v-model="model"
|
||||
:type="type"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
class="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm text-text focus:border-primary focus:outline-2 focus:outline-primary/30 disabled:opacity-50"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ title?: string }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="py-6 pb-12">
|
||||
<div v-if="title || $slots.actions" class="mb-4 flex items-center justify-between">
|
||||
<h1 class="text-xl font-bold text-text">{{ title }}</h1>
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ title?: string }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rounded-lg border border-border bg-surface shadow-sm">
|
||||
<div
|
||||
v-if="title || $slots.actions"
|
||||
class="flex items-center justify-between border-b border-border px-5 py-3"
|
||||
>
|
||||
<h3 class="text-sm font-semibold text-text">{{ title }}</h3>
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
<div class="p-5"><slot /></div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<div class="overflow-x-auto rounded-lg border border-border bg-surface shadow-sm">
|
||||
<table
|
||||
class="w-full border-collapse [&_td]:border-b [&_td]:border-border [&_td]:px-3 [&_td]:py-2.5 [&_td]:text-left [&_td]:text-sm [&_th]:border-b [&_th]:border-border [&_th]:px-3 [&_th]:py-2.5 [&_th]:text-left [&_th]:text-[13px] [&_th]:font-semibold [&_th]:text-muted [&_tr:last-child_td]:border-b-0"
|
||||
>
|
||||
<slot />
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,47 @@
|
||||
import { onMounted } from "vue";
|
||||
import { useHead, useState } from "#app";
|
||||
|
||||
export const ACCENTS = ["red", "blue", "teal", "violet"] as const;
|
||||
export type Accent = (typeof ACCENTS)[number];
|
||||
|
||||
const STORAGE_KEY = "vmall-accent";
|
||||
|
||||
const valid = (value: string | null): value is Accent =>
|
||||
ACCENTS.includes(value as Accent);
|
||||
|
||||
/**
|
||||
* Console accent preset state. `boot()` belongs in the console shell setup:
|
||||
* it injects a pre-paint inline script that restores the stored preset and
|
||||
* syncs reactive state on mount. Invalid stored values fall back to `red`.
|
||||
*/
|
||||
export function useAccent() {
|
||||
const accent = useState<Accent>("vmall-accent", () => "red");
|
||||
|
||||
const setAccent = (value: string) => {
|
||||
const next: Accent = valid(value) ? value : "red";
|
||||
accent.value = next;
|
||||
if (import.meta.client) {
|
||||
localStorage.setItem(STORAGE_KEY, next);
|
||||
document.documentElement.dataset.accent = next;
|
||||
}
|
||||
};
|
||||
|
||||
const boot = () => {
|
||||
useHead({
|
||||
script: [
|
||||
{
|
||||
key: "vmall-accent-boot",
|
||||
innerHTML: `try{var a=localStorage.getItem("${STORAGE_KEY}");document.documentElement.dataset.accent=${JSON.stringify(ACCENTS)}.includes(a)?a:"red";}catch(e){document.documentElement.dataset.accent="red";}`,
|
||||
},
|
||||
],
|
||||
});
|
||||
onMounted(() => {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
const next: Accent = valid(stored) ? stored : "red";
|
||||
accent.value = next;
|
||||
document.documentElement.dataset.accent = next;
|
||||
});
|
||||
};
|
||||
|
||||
return { accent, setAccent, boot };
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export { default as VBtn } from "./components/VBtn.vue";
|
||||
export { default as VBadge } from "./components/VBadge.vue";
|
||||
export { default as VField } from "./components/VField.vue";
|
||||
export { default as VInput } from "./components/VInput.vue";
|
||||
export { default as VCard } from "./components/VCard.vue";
|
||||
export { default as VPanel } from "./components/VPanel.vue";
|
||||
export { default as VTable } from "./components/VTable.vue";
|
||||
export { default as VPage } from "./components/VPage.vue";
|
||||
export { default as VAccentSwatch } from "./components/VAccentSwatch.vue";
|
||||
export { useAccent, ACCENTS, type Accent } from "./composables/useAccent";
|
||||
@@ -0,0 +1,11 @@
|
||||
import { defineNuxtModule, addComponentsDir, addImportsDir } from "@nuxt/kit";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
export default defineNuxtModule({
|
||||
meta: { name: "@vmall/ui", configKey: "vmallUi" },
|
||||
setup() {
|
||||
const resolve = (path: string) => fileURLToPath(new URL(path, import.meta.url));
|
||||
addComponentsDir({ path: resolve("./components"), prefix: "" });
|
||||
addImportsDir(resolve("./composables"));
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user