Restructure omni services and add Chatwoot research snapshot
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
<script setup>
|
||||
import Button from './Button.vue';
|
||||
|
||||
// Constants for documentation
|
||||
const VARIANTS = ['solid', 'outline', 'faded', 'link', 'ghost'];
|
||||
const COLORS = ['blue', 'ruby', 'amber', 'slate', 'teal'];
|
||||
const SIZES = ['default', 'sm', 'lg'];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story title="Components/Button" :layout="{ type: 'grid', width: '800px' }">
|
||||
<!-- Basic Variants -->
|
||||
<Variant title="Basic Variants">
|
||||
<div class="flex flex-wrap gap-2 p-4 bg-n-background">
|
||||
<Button
|
||||
v-for="variant in VARIANTS"
|
||||
:key="variant"
|
||||
:label="variant"
|
||||
:variant="variant"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<!-- Colors -->
|
||||
<Variant title="Color Variants">
|
||||
<div class="flex flex-wrap gap-2 p-4 bg-n-background">
|
||||
<Button
|
||||
v-for="color in COLORS"
|
||||
:key="color"
|
||||
:label="color"
|
||||
:color="color"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<!-- Sizes -->
|
||||
<Variant title="Size Variants">
|
||||
<div class="flex flex-wrap items-center gap-2 p-4 bg-n-background">
|
||||
<Button v-for="size in SIZES" :key="size" :label="size" :size="size" />
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<!-- Icons -->
|
||||
<Variant title="Icons">
|
||||
<div class="flex flex-wrap gap-2 p-4 bg-n-background">
|
||||
<Button label="Leading Icon" icon="i-lucide-plus" />
|
||||
<Button label="Trailing Icon" icon="i-lucide-plus" trailing-icon />
|
||||
<Button icon="i-lucide-plus" />
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<!-- Loading State -->
|
||||
<Variant title="Loading State">
|
||||
<div class="flex flex-wrap gap-2 p-4 bg-n-background">
|
||||
<Button label="Loading" is-loading />
|
||||
<Button label="Loading" variant="outline" is-loading />
|
||||
<Button is-loading icon="i-lucide-plus" />
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<!-- Disabled State -->
|
||||
<Variant title="Disabled State">
|
||||
<div class="flex flex-wrap gap-2 p-4 bg-n-background">
|
||||
<Button label="Disabled" disabled />
|
||||
<Button label="Disabled Outline" variant="outline" disabled />
|
||||
<Button label="Disabled Icon" icon="delete" disabled />
|
||||
<Button
|
||||
label="Disabled Destructive"
|
||||
color="ruby"
|
||||
disabled
|
||||
icon="delete"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<!-- Color Combinations -->
|
||||
<Variant title="Color & Variant Combinations">
|
||||
<div class="flex flex-wrap gap-2 p-4 bg-n-background">
|
||||
<template v-for="color in COLORS" :key="color">
|
||||
<Button
|
||||
v-for="variant in VARIANTS"
|
||||
:key="`${color}-${variant}`"
|
||||
:label="`${color} ${variant}`"
|
||||
:color="color"
|
||||
:variant="variant"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<!-- Icon Positions -->
|
||||
<Variant title="Icon Positions & Sizes">
|
||||
<div class="flex flex-wrap gap-2 p-4 bg-n-background">
|
||||
<template v-for="size in SIZES" :key="size">
|
||||
<Button
|
||||
:label="`${size} Leading`"
|
||||
icon="i-lucide-plus"
|
||||
:size="size"
|
||||
/>
|
||||
<Button
|
||||
:label="`${size} Trailing`"
|
||||
icon="i-lucide-plus"
|
||||
trailing-icon
|
||||
:size="size"
|
||||
/>
|
||||
<Button icon="i-lucide-plus" :size="size" />
|
||||
</template>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<!-- Ghost & Link Variants -->
|
||||
<Variant title="Ghost & Link Variants">
|
||||
<div class="flex flex-wrap gap-2 p-4 bg-n-background">
|
||||
<Button label="Ghost Button" variant="ghost" color="slate" />
|
||||
<Button
|
||||
label="Ghost with Icon"
|
||||
variant="ghost"
|
||||
color="slate"
|
||||
icon="i-lucide-plus"
|
||||
/>
|
||||
<Button label="Link Button" variant="link" />
|
||||
<Button label="Link with Icon" variant="link" icon="i-lucide-plus" />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,261 @@
|
||||
<script setup>
|
||||
import { computed, useSlots, useAttrs } from 'vue';
|
||||
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import {
|
||||
VARIANT_OPTIONS,
|
||||
COLOR_OPTIONS,
|
||||
JUSTIFY_OPTIONS,
|
||||
SIZE_OPTIONS,
|
||||
EXCLUDED_ATTRS,
|
||||
} from './constants.js';
|
||||
|
||||
const props = defineProps({
|
||||
label: { type: [String, Number], default: '' },
|
||||
variant: {
|
||||
type: String,
|
||||
default: null,
|
||||
validator: value => VARIANT_OPTIONS.includes(value) || value === null,
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: null,
|
||||
validator: value => COLOR_OPTIONS.includes(value) || value === null,
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: null,
|
||||
validator: value => SIZE_OPTIONS.includes(value) || value === null,
|
||||
},
|
||||
justify: {
|
||||
type: String,
|
||||
default: null,
|
||||
validator: value => JUSTIFY_OPTIONS.includes(value) || value === null,
|
||||
},
|
||||
icon: { type: [String, Object, Function], default: '' },
|
||||
trailingIcon: { type: Boolean, default: false },
|
||||
isLoading: { type: Boolean, default: false },
|
||||
noAnimation: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const slots = useSlots();
|
||||
const attrs = useAttrs();
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const filteredAttrs = computed(() => {
|
||||
const standardAttrs = {};
|
||||
|
||||
Object.entries(attrs)
|
||||
.filter(([key]) => !EXCLUDED_ATTRS.includes(key))
|
||||
.forEach(([key, value]) => {
|
||||
standardAttrs[key] = value;
|
||||
});
|
||||
|
||||
return standardAttrs;
|
||||
});
|
||||
|
||||
const computedVariant = computed(() => {
|
||||
if (props.variant) return props.variant;
|
||||
// The useAttrs method returns attributes values an empty string (not boolean value as in props).
|
||||
if (attrs.solid || attrs.solid === '') return 'solid';
|
||||
if (attrs.outline || attrs.outline === '') return 'outline';
|
||||
if (attrs.faded || attrs.faded === '') return 'faded';
|
||||
if (attrs.link || attrs.link === '') return 'link';
|
||||
if (attrs.ghost || attrs.ghost === '') return 'ghost';
|
||||
return 'solid'; // Default variant
|
||||
});
|
||||
|
||||
const computedColor = computed(() => {
|
||||
if (props.color) return props.color;
|
||||
if (attrs.blue || attrs.blue === '') return 'blue';
|
||||
if (attrs.ruby || attrs.ruby === '') return 'ruby';
|
||||
if (attrs.amber || attrs.amber === '') return 'amber';
|
||||
if (attrs.slate || attrs.slate === '') return 'slate';
|
||||
if (attrs.teal || attrs.teal === '') return 'teal';
|
||||
return 'blue'; // Default color
|
||||
});
|
||||
|
||||
const computedSize = computed(() => {
|
||||
if (props.size) return props.size;
|
||||
if (attrs.xs || attrs.xs === '') return 'xs';
|
||||
if (attrs.sm || attrs.sm === '') return 'sm';
|
||||
if (attrs.md || attrs.md === '') return 'md';
|
||||
if (attrs.lg || attrs.lg === '') return 'lg';
|
||||
return 'md';
|
||||
});
|
||||
|
||||
const computedJustify = computed(() => {
|
||||
if (props.justify) return props.justify;
|
||||
if (attrs.start || attrs.start === '') return 'start';
|
||||
if (attrs.center || attrs.center === '') return 'center';
|
||||
if (attrs.end || attrs.end === '') return 'end';
|
||||
|
||||
return 'center';
|
||||
});
|
||||
|
||||
const STYLE_CONFIG = {
|
||||
colors: {
|
||||
blue: {
|
||||
solid:
|
||||
'bg-n-brand text-white hover:enabled:brightness-110 focus-visible:brightness-110 outline-transparent',
|
||||
faded:
|
||||
'bg-n-brand/10 text-n-blue-11 hover:enabled:bg-n-brand/20 focus-visible:bg-n-brand/20 outline-transparent',
|
||||
outline: 'text-n-blue-11 outline-n-brand',
|
||||
ghost:
|
||||
'text-n-blue-11 hover:enabled:bg-n-alpha-2 focus-visible:bg-n-alpha-2 outline-transparent',
|
||||
link: 'text-n-blue-11 hover:enabled:underline focus-visible:underline outline-transparent',
|
||||
},
|
||||
ruby: {
|
||||
solid:
|
||||
'bg-n-ruby-9 text-white hover:enabled:bg-n-ruby-10 focus-visible:bg-n-ruby-10 outline-transparent',
|
||||
faded:
|
||||
'bg-n-ruby-9/10 text-n-ruby-11 hover:enabled:bg-n-ruby-9/20 focus-visible:bg-n-ruby-9/20 outline-transparent',
|
||||
outline:
|
||||
'text-n-ruby-11 hover:enabled:bg-n-ruby-9/10 focus-visible:bg-n-ruby-9/10 outline-n-ruby-8',
|
||||
ghost:
|
||||
'text-n-ruby-11 hover:enabled:bg-n-alpha-2 focus-visible:bg-n-alpha-2 outline-transparent',
|
||||
link: 'text-n-ruby-9 dark:text-n-ruby-11 hover:enabled:underline focus-visible:underline outline-transparent',
|
||||
},
|
||||
amber: {
|
||||
solid:
|
||||
'bg-n-amber-9 text-white hover:enabled:bg-n-amber-10 focus-visible:bg-n-amber-10 outline-transparent',
|
||||
faded:
|
||||
'bg-n-amber-9/10 text-n-slate-12 hover:enabled:bg-n-amber-9/20 focus-visible:bg-n-amber-9/20 outline-transparent',
|
||||
outline:
|
||||
'text-n-amber-11 hover:enabled:bg-n-amber-9/10 focus-visible:bg-n-amber-9/10 outline-n-amber-9',
|
||||
link: 'text-n-amber-9 hover:enabled:underline focus-visible:underline outline-transparent',
|
||||
ghost:
|
||||
'text-n-amber-9 hover:enabled:bg-n-alpha-2 focus-visible:bg-n-alpha-2 outline-transparent',
|
||||
},
|
||||
slate: {
|
||||
solid:
|
||||
'bg-n-button-color dark:hover:enabled:bg-n-solid-2 dark:focus-visible:bg-n-solid-2 hover:enabled:bg-n-alpha-2 focus-visible:bg-n-alpha-2 text-n-slate-12 outline-n-container',
|
||||
faded:
|
||||
'bg-n-slate-9/10 text-n-slate-12 hover:enabled:bg-n-slate-9/20 focus-visible:bg-n-slate-9/20 outline-transparent',
|
||||
outline:
|
||||
'text-n-slate-11 outline-n-strong hover:enabled:bg-n-slate-9/10 focus-visible:bg-n-slate-9/10',
|
||||
link: 'text-n-slate-11 hover:enabled:text-n-slate-12 focus-visible:text-n-slate-12 hover:enabled:underline focus-visible:underline outline-transparent',
|
||||
ghost:
|
||||
'text-n-slate-12 hover:enabled:bg-n-alpha-2 focus-visible:bg-n-alpha-2 outline-transparent',
|
||||
},
|
||||
teal: {
|
||||
solid:
|
||||
'bg-n-teal-9 text-white hover:enabled:bg-n-teal-10 focus-visible:bg-n-teal-10 outline-transparent',
|
||||
faded:
|
||||
'bg-n-teal-9/10 text-n-teal-11 hover:enabled:bg-n-teal-9/20 focus-visible:bg-n-teal-9/20 outline-transparent',
|
||||
outline:
|
||||
'text-n-teal-11 hover:enabled:bg-n-teal-9/10 focus-visible:bg-n-teal-9/10 outline-n-teal-9',
|
||||
link: 'text-n-teal-9 hover:enabled:underline focus-visible:underline outline-transparent',
|
||||
ghost:
|
||||
'text-n-teal-9 hover:enabled:bg-n-alpha-2 focus-visible:bg-n-alpha-2 outline-transparent',
|
||||
},
|
||||
},
|
||||
sizes: {
|
||||
regular: {
|
||||
xs: 'h-6 px-2',
|
||||
sm: 'h-8 px-3',
|
||||
md: 'h-10 px-4',
|
||||
lg: 'h-12 px-5',
|
||||
},
|
||||
iconOnly: {
|
||||
xs: 'h-6 w-6 p-0',
|
||||
sm: 'h-8 w-8 p-0',
|
||||
md: 'h-10 w-10 p-0',
|
||||
lg: 'h-12 w-12 p-0',
|
||||
},
|
||||
link: {
|
||||
xs: 'p-0',
|
||||
sm: 'p-0',
|
||||
md: 'p-0',
|
||||
lg: 'p-0',
|
||||
},
|
||||
},
|
||||
fontSize: {
|
||||
xs: 'text-xs',
|
||||
sm: 'text-sm',
|
||||
md: 'text-sm font-medium',
|
||||
lg: 'text-base',
|
||||
},
|
||||
clickAnimation: {
|
||||
xs: 'active:enabled:scale-[0.97]',
|
||||
sm: 'active:enabled:scale-[0.97]',
|
||||
md: 'active:enabled:scale-[0.98]',
|
||||
lg: 'active:enabled:scale-[0.98]',
|
||||
},
|
||||
justify: {
|
||||
start: 'justify-start',
|
||||
center: 'justify-center',
|
||||
end: 'justify-end',
|
||||
},
|
||||
base: 'inline-flex items-center min-w-0 gap-2 transition-all duration-100 ease-out border-0 rounded-lg outline-1 outline disabled:opacity-50',
|
||||
};
|
||||
|
||||
const variantClasses = computed(() => {
|
||||
const variantMap = {
|
||||
ghost: `${STYLE_CONFIG.colors[computedColor.value].ghost}`,
|
||||
link: `${STYLE_CONFIG.colors[computedColor.value].link} p-0 font-medium underline-offset-2`,
|
||||
outline: STYLE_CONFIG.colors[computedColor.value].outline,
|
||||
faded: STYLE_CONFIG.colors[computedColor.value].faded,
|
||||
solid: STYLE_CONFIG.colors[computedColor.value].solid,
|
||||
};
|
||||
|
||||
return variantMap[computedVariant.value];
|
||||
});
|
||||
|
||||
const isIconOnly = computed(() => !props.label && !slots.default);
|
||||
const isLink = computed(() => computedVariant.value === 'link');
|
||||
|
||||
const buttonClasses = computed(() => {
|
||||
const sizeConfig = isIconOnly.value ? 'iconOnly' : 'regular';
|
||||
const classes = [
|
||||
variantClasses.value,
|
||||
computedVariant.value !== 'link' &&
|
||||
STYLE_CONFIG.sizes[sizeConfig][computedSize.value],
|
||||
].filter(Boolean);
|
||||
|
||||
return classes.join(' ');
|
||||
});
|
||||
|
||||
const linkButtonClasses = computed(() => {
|
||||
const classes = [
|
||||
variantClasses.value,
|
||||
STYLE_CONFIG.sizes.link[computedSize.value],
|
||||
].filter(Boolean);
|
||||
|
||||
return classes.join(' ');
|
||||
});
|
||||
|
||||
const animationClasses = computed(() => {
|
||||
return props.noAnimation
|
||||
? ''
|
||||
: STYLE_CONFIG.clickAnimation[computedSize.value];
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
v-bind="filteredAttrs"
|
||||
:class="{
|
||||
[STYLE_CONFIG.base]: true,
|
||||
[isLink ? linkButtonClasses : buttonClasses]: true,
|
||||
[STYLE_CONFIG.fontSize[computedSize]]: true,
|
||||
[animationClasses]: true,
|
||||
[STYLE_CONFIG.justify[computedJustify]]: true,
|
||||
'flex-row-reverse': trailingIcon && !isIconOnly,
|
||||
}"
|
||||
>
|
||||
<slot v-if="(icon || $slots.icon) && !isLoading" name="icon">
|
||||
<Icon :icon="icon" class="flex-shrink-0" />
|
||||
</slot>
|
||||
|
||||
<Spinner v-if="isLoading" class="!w-5 !h-5 flex-shrink-0" />
|
||||
|
||||
<slot v-if="label || $slots.default" name="default">
|
||||
<span v-if="label" class="min-w-0 truncate">{{ label }}</span>
|
||||
</slot>
|
||||
</button>
|
||||
</template>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup>
|
||||
import ConfirmButton from './ConfirmButton.vue';
|
||||
import { ref } from 'vue';
|
||||
|
||||
const count = ref(0);
|
||||
|
||||
const incrementCount = () => {
|
||||
count.value += 1;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/ConfirmButton"
|
||||
:layout="{ type: 'grid', width: '400px' }"
|
||||
>
|
||||
<Variant title="Basic">
|
||||
<div class="grid gap-2 p-4 bg-n-background">
|
||||
<p>{{ count }}</p>
|
||||
<ConfirmButton
|
||||
label="Delete"
|
||||
confirm-label="Confirm?"
|
||||
@click="incrementCount"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Color Change">
|
||||
<div class="grid gap-2 p-4 bg-n-background">
|
||||
<p>{{ count }}</p>
|
||||
<ConfirmButton
|
||||
label="Archive"
|
||||
confirm-label="Confirm?"
|
||||
color="slate"
|
||||
confirm-color="amber"
|
||||
@click="incrementCount"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,99 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import Button from './Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
label: { type: [String, Number], default: '' },
|
||||
confirmLabel: { type: [String, Number], default: '' },
|
||||
color: { type: String, default: 'blue' },
|
||||
confirmColor: { type: String, default: 'ruby' },
|
||||
confirmHint: { type: String, default: '' },
|
||||
variant: { type: String, default: null },
|
||||
size: { type: String, default: null },
|
||||
justify: { type: String, default: null },
|
||||
icon: { type: [String, Object, Function], default: '' },
|
||||
trailingIcon: { type: Boolean, default: false },
|
||||
isLoading: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['click']);
|
||||
|
||||
const isConfirmMode = ref(false);
|
||||
const isClicked = ref(false);
|
||||
|
||||
const currentLabel = computed(() => {
|
||||
return isConfirmMode.value ? props.confirmLabel : props.label;
|
||||
});
|
||||
|
||||
const currentColor = computed(() => {
|
||||
return isConfirmMode.value ? props.confirmColor : props.color;
|
||||
});
|
||||
const resetConfirmMode = () => {
|
||||
isConfirmMode.value = false;
|
||||
isClicked.value = false;
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
if (!isConfirmMode.value) {
|
||||
isConfirmMode.value = true;
|
||||
} else {
|
||||
isClicked.value = true;
|
||||
emit('click');
|
||||
setTimeout(resetConfirmMode, 400);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative"
|
||||
:class="{
|
||||
'animate-bounce-complete': isClicked,
|
||||
}"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
:label="currentLabel"
|
||||
:color="currentColor"
|
||||
:variant="variant"
|
||||
:size="size"
|
||||
:justify="justify"
|
||||
:icon="icon"
|
||||
:trailing-icon="trailingIcon"
|
||||
:is-loading="isLoading"
|
||||
@click="handleClick"
|
||||
@blur="resetConfirmMode"
|
||||
>
|
||||
<template v-if="$slots.default" #default>
|
||||
<slot />
|
||||
</template>
|
||||
<template v-if="$slots.icon" #icon>
|
||||
<slot name="icon" />
|
||||
</template>
|
||||
</Button>
|
||||
<div
|
||||
v-if="isConfirmMode && confirmHint"
|
||||
class="absolute mt-1 w-full text-[10px] text-center text-n-slate-10"
|
||||
>
|
||||
{{ confirmHint }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@keyframes bounce-complete {
|
||||
0% {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-bounce-complete {
|
||||
animation: bounce-complete 0.2s cubic-bezier(0.68, -0.55, 0.265, 1.55);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,17 @@
|
||||
export const VARIANT_OPTIONS = ['solid', 'outline', 'faded', 'link', 'ghost'];
|
||||
export const COLOR_OPTIONS = ['blue', 'ruby', 'amber', 'slate', 'teal'];
|
||||
export const SIZE_OPTIONS = ['xs', 'sm', 'md', 'lg'];
|
||||
export const JUSTIFY_OPTIONS = ['start', 'center', 'end'];
|
||||
|
||||
export const EXCLUDED_ATTRS = [
|
||||
'variant',
|
||||
'color',
|
||||
'size',
|
||||
'icon',
|
||||
'trailingIcon',
|
||||
'isLoading',
|
||||
...VARIANT_OPTIONS,
|
||||
...COLOR_OPTIONS,
|
||||
...SIZE_OPTIONS,
|
||||
...JUSTIFY_OPTIONS,
|
||||
];
|
||||
Reference in New Issue
Block a user