Merge design_ui_v2 into design_ui so the new design system can be previewed without dropping later product work.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
variant?: 'neutral' | 'accent' | 'primary' | 'success' | 'warning' | 'danger' | 'info';
|
||||
size?: 'sm' | 'md';
|
||||
dot?: boolean;
|
||||
}>(),
|
||||
{
|
||||
variant: 'neutral',
|
||||
size: 'md',
|
||||
dot: false,
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="ui-badge" :class="[variant, size]">
|
||||
<span v-if="dot" class="badge-dot" aria-hidden="true" />
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ui-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: var(--fw-semibold);
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
border: 1px solid transparent;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.ui-badge.sm {
|
||||
padding: 0.1rem 0.45rem;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.badge-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
/* Variants */
|
||||
.ui-badge.neutral {
|
||||
background: var(--surface-subtle);
|
||||
color: var(--ink-secondary);
|
||||
border-color: var(--line);
|
||||
}
|
||||
|
||||
.ui-badge.accent,
|
||||
.ui-badge.primary {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent-deep);
|
||||
border-color: color-mix(in srgb, var(--accent) 30%, transparent);
|
||||
}
|
||||
|
||||
.ui-badge.success {
|
||||
background: var(--success-soft);
|
||||
color: var(--success-deep);
|
||||
border-color: color-mix(in srgb, var(--success) 30%, transparent);
|
||||
}
|
||||
|
||||
.ui-badge.warning {
|
||||
background: var(--warning-soft);
|
||||
color: var(--warning-deep);
|
||||
border-color: color-mix(in srgb, var(--warning) 30%, transparent);
|
||||
}
|
||||
|
||||
.ui-badge.danger {
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
border-color: color-mix(in srgb, var(--danger) 30%, transparent);
|
||||
}
|
||||
|
||||
.ui-badge.info {
|
||||
background: var(--info-soft);
|
||||
color: var(--info-deep);
|
||||
border-color: color-mix(in srgb, var(--info) 30%, transparent);
|
||||
}
|
||||
</style>
|
||||
@@ -1,12 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
variant?: 'primary' | 'ghost' | 'dashed' | 'icon';
|
||||
type?: 'button' | 'submit';
|
||||
variant?: 'primary' | 'ghost' | 'dashed' | 'icon' | 'danger' | 'outline' | 'link' | 'secondary' | 'success';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
type?: 'button' | 'submit' | 'reset';
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
block?: boolean;
|
||||
}>(),
|
||||
{ variant: 'primary', type: 'button' },
|
||||
{
|
||||
variant: 'primary',
|
||||
size: 'md',
|
||||
type: 'button',
|
||||
disabled: false,
|
||||
loading: false,
|
||||
block: false,
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -14,9 +23,12 @@ withDefaults(
|
||||
<button
|
||||
:type="type"
|
||||
class="ui-btn"
|
||||
:class="[variant, { block }]"
|
||||
:disabled="disabled"
|
||||
:class="[variant, size, { block, loading }]"
|
||||
:disabled="disabled || loading"
|
||||
:aria-busy="loading"
|
||||
>
|
||||
<span v-if="loading" class="spinner" aria-hidden="true"></span>
|
||||
<slot name="icon" />
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
@@ -26,18 +38,52 @@ withDefaults(
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
min-height: var(--control-h);
|
||||
padding: 0 0.95rem;
|
||||
gap: var(--sp-2);
|
||||
min-height: var(--control-h-md);
|
||||
height: var(--control-h-md);
|
||||
padding: 0 0.85rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius);
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
color: var(--accent-contrast);
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
font-size: 0.88rem;
|
||||
font-weight: var(--fw-semibold);
|
||||
font-size: var(--fs-sm);
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
box-sizing: border-box;
|
||||
transition: background var(--transition-fast), border-color var(--transition-fast),
|
||||
box-shadow var(--transition-fast), opacity var(--transition-fast);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Sizes */
|
||||
.ui-btn.sm {
|
||||
min-height: var(--control-h-sm);
|
||||
height: var(--control-h-sm);
|
||||
padding: 0 0.65rem;
|
||||
font-size: var(--fs-xs);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.ui-btn.md {
|
||||
min-height: var(--control-h-md);
|
||||
height: var(--control-h-md);
|
||||
padding: 0 0.85rem;
|
||||
font-size: var(--fs-sm);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.ui-btn.lg {
|
||||
min-height: var(--control-h-lg);
|
||||
height: var(--control-h-lg);
|
||||
padding: 0 1.15rem;
|
||||
font-size: var(--fs-base);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.ui-btn:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.ui-btn:disabled {
|
||||
@@ -49,28 +95,125 @@ withDefaults(
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Variants */
|
||||
.ui-btn.ghost {
|
||||
background: #fff;
|
||||
background: transparent;
|
||||
color: var(--ink);
|
||||
border-color: var(--line);
|
||||
}
|
||||
|
||||
.ui-btn.ghost:hover:not(:disabled) {
|
||||
background: var(--surface-subtle);
|
||||
border-color: var(--line-strong);
|
||||
}
|
||||
|
||||
.ui-btn.outline {
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.ui-btn.outline:hover:not(:disabled) {
|
||||
background: var(--accent-subtle);
|
||||
}
|
||||
|
||||
.ui-btn.secondary {
|
||||
background: var(--surface-subtle);
|
||||
color: var(--ink-secondary);
|
||||
border-color: var(--line);
|
||||
}
|
||||
|
||||
.ui-btn.secondary:hover:not(:disabled) {
|
||||
background: var(--line);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.ui-btn.danger {
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
.ui-btn.danger:hover:not(:disabled) {
|
||||
background: var(--danger-hover);
|
||||
}
|
||||
|
||||
.ui-btn.success {
|
||||
background: var(--success);
|
||||
color: #fff;
|
||||
border-color: var(--success);
|
||||
}
|
||||
|
||||
.ui-btn.success:hover:not(:disabled) {
|
||||
background: var(--success-deep);
|
||||
}
|
||||
|
||||
.ui-btn.dashed {
|
||||
background: #fff;
|
||||
border: 1.5px dashed currentColor;
|
||||
font-weight: 700;
|
||||
color: var(--muted);
|
||||
border: 1.5px dashed var(--line-strong);
|
||||
}
|
||||
|
||||
.ui-btn.dashed:hover:not(:disabled) {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-subtle);
|
||||
}
|
||||
|
||||
.ui-btn.link {
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
color: var(--accent);
|
||||
padding: 0 var(--sp-1);
|
||||
min-height: auto;
|
||||
font-weight: var(--fw-semibold);
|
||||
}
|
||||
|
||||
.ui-btn.link:hover:not(:disabled) {
|
||||
background: transparent;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.ui-btn.icon {
|
||||
width: var(--control-h);
|
||||
width: var(--control-h-md);
|
||||
padding: 0;
|
||||
background: #fff;
|
||||
color: var(--accent-deep);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.ui-btn.icon.sm {
|
||||
width: var(--control-h-sm);
|
||||
min-height: var(--control-h-sm);
|
||||
}
|
||||
|
||||
.ui-btn.icon.lg {
|
||||
width: var(--control-h-lg);
|
||||
min-height: var(--control-h-lg);
|
||||
}
|
||||
|
||||
.ui-btn.icon:hover:not(:disabled) {
|
||||
background: var(--accent-subtle);
|
||||
}
|
||||
|
||||
.ui-btn:focus-visible {
|
||||
outline: 2px solid var(--accent-soft);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* Spinner */
|
||||
.spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid currentColor;
|
||||
border-right-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: ui-spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes ui-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,14 +1,119 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
padding?: 'none' | 'sm' | 'md' | 'lg';
|
||||
shadow?: 'none' | 'xs' | 'sm' | 'md';
|
||||
interactive?: boolean;
|
||||
title?: string;
|
||||
}>(),
|
||||
{
|
||||
padding: 'md',
|
||||
shadow: 'xs',
|
||||
interactive: false,
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ui-card">
|
||||
<slot />
|
||||
<div
|
||||
class="ui-card"
|
||||
:class="[
|
||||
`pad-${padding}`,
|
||||
`shadow-${shadow}`,
|
||||
{ interactive }
|
||||
]"
|
||||
>
|
||||
<header v-if="title || $slots.header" class="card-head">
|
||||
<slot name="header">
|
||||
<h3 v-if="title" class="card-title">{{ title }}</h3>
|
||||
</slot>
|
||||
<div v-if="$slots.actions" class="card-actions">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</header>
|
||||
<div class="card-body">
|
||||
<slot />
|
||||
</div>
|
||||
<footer v-if="$slots.footer" class="card-foot">
|
||||
<slot name="footer" />
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ui-card {
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: var(--pad-modal);
|
||||
box-sizing: border-box;
|
||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
/* Padding variants */
|
||||
.ui-card.pad-none {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.ui-card.pad-sm {
|
||||
padding: var(--sp-3);
|
||||
}
|
||||
|
||||
.ui-card.pad-md {
|
||||
padding: var(--sp-5);
|
||||
}
|
||||
|
||||
.ui-card.pad-lg {
|
||||
padding: var(--sp-6);
|
||||
}
|
||||
|
||||
/* Shadow variants */
|
||||
.ui-card.shadow-none {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.ui-card.shadow-xs {
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
|
||||
.ui-card.shadow-sm {
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.ui-card.shadow-md {
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.ui-card.interactive:hover {
|
||||
border-color: var(--line-strong);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-3);
|
||||
padding-bottom: var(--sp-3);
|
||||
margin-bottom: var(--sp-4);
|
||||
border-bottom: 1px solid var(--line-light);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
margin: 0;
|
||||
font-size: var(--fs-md);
|
||||
font-weight: var(--fw-bold);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.card-foot {
|
||||
padding-top: var(--sp-3);
|
||||
margin-top: var(--sp-4);
|
||||
border-top: 1px solid var(--line-light);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,54 +1,114 @@
|
||||
<script setup lang="ts">
|
||||
<script lang="ts">
|
||||
import { use } from 'echarts/core';
|
||||
import { CanvasRenderer } from 'echarts/renderers';
|
||||
import { LineChart, PieChart } from 'echarts/charts';
|
||||
import { LineChart, PieChart, BarChart } from 'echarts/charts';
|
||||
import {
|
||||
GridComponent,
|
||||
LegendComponent,
|
||||
TooltipComponent,
|
||||
TitleComponent,
|
||||
} from 'echarts/components';
|
||||
import VChart from 'vue-echarts';
|
||||
import type { ComposeOption } from 'echarts/core';
|
||||
import type { LineSeriesOption, PieSeriesOption } from 'echarts/charts';
|
||||
import type { LineSeriesOption, PieSeriesOption, BarSeriesOption } from 'echarts/charts';
|
||||
import type {
|
||||
GridComponentOption,
|
||||
LegendComponentOption,
|
||||
TooltipComponentOption,
|
||||
TitleComponentOption,
|
||||
} from 'echarts/components';
|
||||
|
||||
use([
|
||||
CanvasRenderer,
|
||||
LineChart,
|
||||
PieChart,
|
||||
BarChart,
|
||||
GridComponent,
|
||||
TooltipComponent,
|
||||
LegendComponent,
|
||||
TitleComponent,
|
||||
]);
|
||||
|
||||
export type UiChartOption = ComposeOption<
|
||||
| LineSeriesOption
|
||||
| PieSeriesOption
|
||||
| BarSeriesOption
|
||||
| GridComponentOption
|
||||
| TooltipComponentOption
|
||||
| LegendComponentOption
|
||||
| TitleComponentOption
|
||||
>;
|
||||
|
||||
/**
|
||||
* Shared palette matching Montact CRM design tokens
|
||||
*/
|
||||
export const CHART_PALETTE = [
|
||||
'#14b8a6', // Teal (Accent)
|
||||
'#3b82f6', // Blue
|
||||
'#f59e0b', // Amber
|
||||
'#10b981', // Emerald
|
||||
'#8b5cf6', // Violet
|
||||
'#f43f5e', // Rose
|
||||
'#06b6d4', // Cyan
|
||||
'#eab308', // Yellow
|
||||
];
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import VChart from 'vue-echarts';
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
option: UiChartOption;
|
||||
height?: string;
|
||||
loading?: boolean;
|
||||
}>(),
|
||||
{ height: '220px' },
|
||||
{ height: '260px', loading: false },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VChart class="ui-chart" :option="option" :style="{ height }" autoresize />
|
||||
<div class="ui-chart-container" :style="{ height }">
|
||||
<div v-if="loading" class="chart-loading">
|
||||
<div class="chart-spinner"></div>
|
||||
</div>
|
||||
<VChart v-else class="ui-chart" :option="option" autoresize />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ui-chart {
|
||||
.ui-chart-container {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ui-chart {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chart-loading {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.chart-spinner {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 3px solid var(--line);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: chart-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes chart-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
<script setup lang="ts">
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
|
||||
type IconName = InstanceType<typeof UiIcon>['$props']['name'];
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
title?: string;
|
||||
description?: string;
|
||||
icon?: IconName;
|
||||
}>(),
|
||||
{
|
||||
title: 'موردی یافت نشد',
|
||||
icon: 'search',
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ui-empty-state">
|
||||
<div class="icon-wrap">
|
||||
<slot name="icon">
|
||||
<UiIcon :name="icon" :size="32" />
|
||||
</slot>
|
||||
</div>
|
||||
<h3 class="title">{{ title }}</h3>
|
||||
<p v-if="description" class="description">{{ description }}</p>
|
||||
<slot />
|
||||
<div v-if="$slots.action" class="action-wrap">
|
||||
<slot name="action" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ui-empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: var(--sp-10) var(--sp-6);
|
||||
gap: var(--sp-3);
|
||||
color: var(--muted);
|
||||
background: var(--surface);
|
||||
border: 1px dashed var(--line-strong);
|
||||
border-radius: var(--radius);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.icon-wrap {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
background: var(--surface-subtle);
|
||||
color: var(--muted);
|
||||
margin-bottom: var(--sp-1);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: var(--fs-md);
|
||||
font-weight: var(--fw-bold);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 0;
|
||||
font-size: var(--fs-sm);
|
||||
max-width: 360px;
|
||||
line-height: var(--lh-relaxed);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.action-wrap {
|
||||
margin-top: var(--sp-2);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,104 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
label?: string;
|
||||
forId?: string;
|
||||
required?: boolean;
|
||||
hint?: string;
|
||||
error?: string | boolean;
|
||||
horizontal?: boolean;
|
||||
}>(),
|
||||
{
|
||||
required: false,
|
||||
horizontal: false,
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ui-field" :class="{ horizontal, hasError: !!error }">
|
||||
<div v-if="label || $slots.label" class="label-row">
|
||||
<label v-if="label" :for="forId" class="field-label">
|
||||
{{ label }}
|
||||
<span v-if="required" class="required-mark" aria-hidden="true">*</span>
|
||||
</label>
|
||||
<slot v-else name="label" />
|
||||
<slot name="label-extra" />
|
||||
</div>
|
||||
|
||||
<div class="control-wrap">
|
||||
<slot />
|
||||
<p v-if="typeof error === 'string' && error" class="field-error" role="alert">
|
||||
{{ error }}
|
||||
</p>
|
||||
<p v-else-if="hint || $slots.hint" class="field-hint">
|
||||
<slot name="hint">{{ hint }}</slot>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ui-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-1);
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ui-field.horizontal {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.ui-field.horizontal .label-row {
|
||||
flex: 0 0 140px;
|
||||
}
|
||||
|
||||
.ui-field.horizontal .control-wrap {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.label-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: var(--fw-semibold);
|
||||
color: var(--ink-secondary);
|
||||
line-height: var(--lh-tight);
|
||||
}
|
||||
|
||||
.required-mark {
|
||||
color: var(--danger);
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.control-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.field-error {
|
||||
margin: 2px 0 0;
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--danger);
|
||||
font-weight: var(--fw-medium);
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
margin: 2px 0 0;
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--muted);
|
||||
line-height: var(--lh-normal);
|
||||
}
|
||||
</style>
|
||||
@@ -21,7 +21,7 @@ const open = ref(false);
|
||||
<div class="row">
|
||||
<slot name="before" />
|
||||
<UiSearch v-model="search" :placeholder="placeholder" class="search" />
|
||||
<UiButton v-if="showFilters" type="button" @click="open = !open">
|
||||
<UiButton v-if="showFilters" variant="secondary" type="button" @click="open = !open">
|
||||
<UiIcon name="filter" :size="16" />
|
||||
فیلترها
|
||||
</UiButton>
|
||||
@@ -54,9 +54,10 @@ const open = ref(false);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr));
|
||||
gap: 0.65rem;
|
||||
padding: var(--pad-modal);
|
||||
padding: var(--sp-4);
|
||||
background: #fff;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,48 +4,122 @@ withDefaults(
|
||||
type?: string;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
readonly?: boolean;
|
||||
required?: boolean;
|
||||
maxlength?: number;
|
||||
minlength?: number;
|
||||
error?: boolean | string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
maxlength?: number | string;
|
||||
minlength?: number | string;
|
||||
id?: string;
|
||||
autocomplete?: string;
|
||||
}>(),
|
||||
{ type: 'text' },
|
||||
{
|
||||
type: 'text',
|
||||
size: 'md',
|
||||
disabled: false,
|
||||
readonly: false,
|
||||
required: false,
|
||||
},
|
||||
);
|
||||
|
||||
const model = defineModel<string>({ default: '' });
|
||||
const model = defineModel<string | number>({ default: '' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input
|
||||
v-model="model"
|
||||
class="ui-input"
|
||||
:type="type"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:required="required"
|
||||
:maxlength="maxlength"
|
||||
:minlength="minlength"
|
||||
/>
|
||||
<div class="ui-input-wrapper" :class="[size, { disabled, hasError: !!error }]">
|
||||
<slot name="prefix" />
|
||||
<input
|
||||
:id="id"
|
||||
v-model="model"
|
||||
class="ui-input"
|
||||
:type="type"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:readonly="readonly"
|
||||
:required="required"
|
||||
:maxlength="maxlength"
|
||||
:minlength="minlength"
|
||||
:autocomplete="autocomplete"
|
||||
:aria-invalid="!!error"
|
||||
/>
|
||||
<slot name="suffix" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ui-input {
|
||||
.ui-input-wrapper {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: var(--control-h);
|
||||
padding: 0 0.85rem;
|
||||
min-height: var(--control-h-md);
|
||||
padding: 0 var(--sp-3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.ui-input:focus {
|
||||
.ui-input-wrapper.sm {
|
||||
min-height: var(--control-h-sm);
|
||||
padding: 0 var(--sp-2);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
|
||||
.ui-input-wrapper.md {
|
||||
min-height: var(--control-h-md);
|
||||
padding: 0 var(--sp-3);
|
||||
border-radius: var(--radius);
|
||||
font-size: var(--fs-base);
|
||||
}
|
||||
|
||||
.ui-input-wrapper.lg {
|
||||
min-height: var(--control-h-lg);
|
||||
padding: 0 var(--sp-4);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--fs-md);
|
||||
}
|
||||
|
||||
.ui-input {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.ui-input::placeholder {
|
||||
color: var(--muted-light);
|
||||
}
|
||||
|
||||
.ui-input-wrapper:focus-within {
|
||||
outline: 2px solid var(--accent-soft);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.ui-input-wrapper.hasError {
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
.ui-input-wrapper.hasError:focus-within {
|
||||
outline: 2px solid var(--danger-soft);
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
.ui-input-wrapper.disabled {
|
||||
background: var(--surface-subtle);
|
||||
opacity: 0.65;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.ui-input:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -61,7 +61,7 @@ function onBackdrop(event: MouseEvent) {
|
||||
z-index: 80;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 1.25rem;
|
||||
padding: 16px;
|
||||
background: rgba(15, 23, 42, 0.35);
|
||||
}
|
||||
|
||||
@@ -72,55 +72,122 @@ function onBackdrop(event: MouseEvent) {
|
||||
flex-direction: column;
|
||||
background: #fff;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
border-radius: var(--radius, 10px);
|
||||
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.16);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.head,
|
||||
.body,
|
||||
.foot {
|
||||
padding: var(--pad-modal);
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding-bottom: 0;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
flex-shrink: 0;
|
||||
background: #fff;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.head h2 {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 800;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--ink);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.close {
|
||||
width: var(--control-h);
|
||||
height: var(--control-h);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
border-radius: var(--radius, 10px);
|
||||
background: #fff;
|
||||
color: var(--muted);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.close:hover {
|
||||
background: var(--surface-subtle);
|
||||
color: var(--ink);
|
||||
border-color: var(--line-strong);
|
||||
}
|
||||
|
||||
.body {
|
||||
overflow: auto;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
padding-top: 1rem;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 16px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.body :deep(.crm-page.embedded),
|
||||
.body :deep(.page.embedded),
|
||||
.body :deep(.product-form-page.embedded),
|
||||
.body :deep(.company-form-page.embedded) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.body :deep(.page.embedded > .form),
|
||||
.body :deep(.page.embedded > form),
|
||||
.body :deep(.crm-page.embedded > .form),
|
||||
.body :deep(.crm-page.embedded > form),
|
||||
.body :deep(.crm-page.embedded > .crm-form) {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.body :deep(.embed-actions),
|
||||
.body :deep(.embedded-actions),
|
||||
.body :deep(.form-bottom-actions),
|
||||
.body :deep(.form-footer-actions) {
|
||||
order: 99;
|
||||
flex-shrink: 0;
|
||||
position: sticky;
|
||||
bottom: -16px;
|
||||
margin-top: auto;
|
||||
margin-inline: -16px;
|
||||
margin-bottom: -16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: #fff;
|
||||
border-top: 1px solid var(--line);
|
||||
z-index: 10;
|
||||
box-shadow: 0 -4px 14px rgba(15, 23, 42, 0.05);
|
||||
}
|
||||
|
||||
.body :deep(.embed-actions .btn),
|
||||
.body :deep(.embedded-actions .btn),
|
||||
.body :deep(.embed-actions .ui-btn),
|
||||
.body :deep(.embedded-actions .ui-btn),
|
||||
.body :deep(.form-bottom-actions .btn),
|
||||
.body :deep(.form-bottom-actions .ui-btn),
|
||||
.body :deep(.form-footer-actions .btn),
|
||||
.body :deep(.form-footer-actions .ui-btn) {
|
||||
border-radius: var(--radius, 10px);
|
||||
}
|
||||
|
||||
.foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 0.5rem;
|
||||
padding-top: 0;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border-top: 1px solid var(--line);
|
||||
flex-shrink: 0;
|
||||
background: #fff;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
@@ -138,62 +205,15 @@ function onBackdrop(event: MouseEvent) {
|
||||
}
|
||||
|
||||
.head {
|
||||
flex-shrink: 0;
|
||||
padding: 0.75rem 1rem 0;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.body:has(.page.embedded) {
|
||||
overflow: hidden;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.body :deep(.page.embedded) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.body :deep(.page.embedded > .form),
|
||||
.body :deep(.page.embedded > form) {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.body :deep(.embed-actions) {
|
||||
order: 20;
|
||||
flex: 0 0 auto;
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
margin-top: auto;
|
||||
margin-inline: -1rem;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem calc(0.75rem + env(safe-area-inset-bottom));
|
||||
background: #fff;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.body :deep(.embed-actions .btn) {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.foot {
|
||||
flex-shrink: 0;
|
||||
padding: 0.75rem 1rem calc(0.75rem + env(safe-area-inset-bottom));
|
||||
padding: 16px calc(16px + env(safe-area-inset-bottom));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterLink } from 'vue-router';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
title?: string;
|
||||
lead?: string;
|
||||
description?: string;
|
||||
backTo?: string | object;
|
||||
}>(),
|
||||
{},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="ui-page-header">
|
||||
<div class="header-main">
|
||||
<RouterLink v-if="backTo" :to="backTo" class="back-link" title="بازگشت">
|
||||
<UiIcon name="arrow-right" :size="18" />
|
||||
</RouterLink>
|
||||
|
||||
<div class="titles">
|
||||
<h1 v-if="title" class="title">{{ title }}</h1>
|
||||
<slot v-else name="title" />
|
||||
|
||||
<p v-if="lead || description" class="lead">{{ lead || description }}</p>
|
||||
<slot v-else name="lead" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="$slots.actions" class="actions">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ui-page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-4);
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--control-h-md);
|
||||
height: var(--control-h-md);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
color: var(--ink-secondary);
|
||||
transition: background var(--transition-fast), border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
background: var(--surface-subtle);
|
||||
border-color: var(--line-strong);
|
||||
color: var(--accent-deep);
|
||||
}
|
||||
|
||||
.titles {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: var(--fs-2xl);
|
||||
font-weight: var(--fw-extrabold);
|
||||
color: var(--ink);
|
||||
line-height: var(--lh-tight);
|
||||
}
|
||||
|
||||
.lead {
|
||||
margin: 0;
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--muted);
|
||||
line-height: var(--lh-normal);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.ui-page-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: var(--fs-xl);
|
||||
}
|
||||
|
||||
.actions {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.actions > * {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,142 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import UiButton from './UiButton.vue';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
page: number;
|
||||
totalPages?: number;
|
||||
totalItems?: number;
|
||||
pageSize?: number;
|
||||
}>(),
|
||||
{
|
||||
page: 1,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:page': [page: number];
|
||||
change: [page: number];
|
||||
}>();
|
||||
|
||||
const effectiveTotalPages = computed(() => {
|
||||
if (props.totalPages != null) return Math.max(1, props.totalPages);
|
||||
if (props.totalItems != null && props.pageSize != null && props.pageSize > 0) {
|
||||
return Math.max(1, Math.ceil(props.totalItems / props.pageSize));
|
||||
}
|
||||
return 1;
|
||||
});
|
||||
|
||||
function goTo(p: number) {
|
||||
if (p < 1 || p > effectiveTotalPages.value || p === props.page) return;
|
||||
emit('update:page', p);
|
||||
emit('change', p);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav v-if="effectiveTotalPages > 1 || totalItems !== undefined" class="ui-pagination" aria-label="صفحهبندی">
|
||||
<div class="pagination-info">
|
||||
<span v-if="totalItems !== undefined">
|
||||
مجموع: <strong>{{ totalItems }}</strong> مورد
|
||||
</span>
|
||||
<span v-if="effectiveTotalPages > 1" class="page-count">
|
||||
(صفحه {{ page }} از {{ effectiveTotalPages }})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="effectiveTotalPages > 1" class="pagination-controls">
|
||||
<UiButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:disabled="page <= 1"
|
||||
title="صفحه اول"
|
||||
@click="goTo(1)"
|
||||
>
|
||||
<UiIcon name="chevrons-right" :size="14" />
|
||||
</UiButton>
|
||||
|
||||
<UiButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:disabled="page <= 1"
|
||||
title="صفحه قبل"
|
||||
@click="goTo(page - 1)"
|
||||
>
|
||||
<UiIcon name="chevron-right" :size="14" />
|
||||
قبلی
|
||||
</UiButton>
|
||||
|
||||
<span class="current-indicator">
|
||||
{{ page }} / {{ effectiveTotalPages }}
|
||||
</span>
|
||||
|
||||
<UiButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:disabled="page >= effectiveTotalPages"
|
||||
title="صفحه بعد"
|
||||
@click="goTo(page + 1)"
|
||||
>
|
||||
بعدی
|
||||
<UiIcon name="chevron-left" :size="14" />
|
||||
</UiButton>
|
||||
|
||||
<UiButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:disabled="page >= effectiveTotalPages"
|
||||
title="صفحه آخر"
|
||||
@click="goTo(effectiveTotalPages)"
|
||||
>
|
||||
<UiIcon name="chevrons-left" :size="14" />
|
||||
</UiButton>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ui-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-3);
|
||||
padding-top: var(--sp-4);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pagination-info {
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.page-count {
|
||||
color: var(--muted-light);
|
||||
}
|
||||
|
||||
.pagination-controls {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-1);
|
||||
}
|
||||
|
||||
.current-indicator {
|
||||
padding: 0 var(--sp-2);
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: var(--fw-bold);
|
||||
color: var(--ink-secondary);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.ui-pagination {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -29,12 +29,15 @@ const model = defineModel<string>({ default: '' });
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
min-width: 0;
|
||||
height: var(--control-h);
|
||||
min-height: var(--control-h);
|
||||
padding: 0 0.75rem;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
color: var(--muted);
|
||||
box-sizing: border-box;
|
||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.ui-search input {
|
||||
@@ -45,11 +48,14 @@ const model = defineModel<string>({ default: '' });
|
||||
background: transparent;
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
font-size: var(--fs-sm);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.ui-search:focus-within {
|
||||
outline: 2px solid var(--accent-soft);
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px var(--accent-soft);
|
||||
}
|
||||
|
||||
.sr {
|
||||
|
||||
@@ -1,19 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
|
||||
defineProps<{
|
||||
disabled?: boolean;
|
||||
}>();
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
disabled?: boolean;
|
||||
error?: boolean | string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
id?: string;
|
||||
required?: boolean;
|
||||
}>(),
|
||||
{
|
||||
disabled: false,
|
||||
size: 'md',
|
||||
required: false,
|
||||
},
|
||||
);
|
||||
|
||||
const model = defineModel<string>({ default: '' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<label class="ui-select">
|
||||
<select v-model="model" :disabled="disabled">
|
||||
<label class="ui-select" :class="[size, { disabled, hasError: !!error }]">
|
||||
<select :id="id" v-model="model" :disabled="disabled" :required="required" :aria-invalid="!!error">
|
||||
<slot />
|
||||
</select>
|
||||
<UiIcon class="chev" name="chevron" :size="16" />
|
||||
<UiIcon class="chev" name="chevron" :size="size === 'sm' ? 14 : 16" />
|
||||
</label>
|
||||
</template>
|
||||
|
||||
@@ -22,20 +33,45 @@ const model = defineModel<string>({ default: '' });
|
||||
position: relative;
|
||||
display: block;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ui-select select {
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
min-height: var(--control-h);
|
||||
padding: 0 2rem 0 0.85rem;
|
||||
min-height: var(--control-h-md);
|
||||
padding: 0 2rem 0 var(--sp-3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
font-weight: var(--fw-medium);
|
||||
font-size: var(--fs-base);
|
||||
cursor: pointer;
|
||||
box-sizing: border-box;
|
||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.ui-select.sm select {
|
||||
min-height: var(--control-h-sm);
|
||||
padding: 0 1.75rem 0 var(--sp-2);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
|
||||
.ui-select.md select {
|
||||
min-height: var(--control-h-md);
|
||||
padding: 0 2rem 0 var(--sp-3);
|
||||
border-radius: var(--radius);
|
||||
font-size: var(--fs-base);
|
||||
}
|
||||
|
||||
.ui-select.lg select {
|
||||
min-height: var(--control-h-lg);
|
||||
padding: 0 2.25rem 0 var(--sp-4);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--fs-md);
|
||||
}
|
||||
|
||||
.ui-select select:focus {
|
||||
@@ -43,6 +79,21 @@ const model = defineModel<string>({ default: '' });
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.ui-select.hasError select {
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
.ui-select.hasError select:focus {
|
||||
outline: 2px solid var(--danger-soft);
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
.ui-select.disabled select {
|
||||
background: var(--surface-subtle);
|
||||
opacity: 0.65;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.chev {
|
||||
position: absolute;
|
||||
left: 0.65rem;
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
readonly?: boolean;
|
||||
required?: boolean;
|
||||
error?: boolean | string;
|
||||
rows?: number;
|
||||
maxlength?: number;
|
||||
id?: string;
|
||||
}>(),
|
||||
{
|
||||
rows: 3,
|
||||
disabled: false,
|
||||
readonly: false,
|
||||
required: false,
|
||||
},
|
||||
);
|
||||
|
||||
const model = defineModel<string>({ default: '' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<textarea
|
||||
:id="id"
|
||||
v-model="model"
|
||||
class="ui-textarea"
|
||||
:class="{ disabled, hasError: !!error }"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:readonly="readonly"
|
||||
:required="required"
|
||||
:rows="rows"
|
||||
:maxlength="maxlength"
|
||||
:aria-invalid="!!error"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ui-textarea {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
padding: var(--sp-3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
font-size: var(--fs-base);
|
||||
line-height: var(--lh-normal);
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.ui-textarea::placeholder {
|
||||
color: var(--muted-light);
|
||||
}
|
||||
|
||||
.ui-textarea:focus {
|
||||
outline: 2px solid var(--accent-soft);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.ui-textarea.hasError {
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
.ui-textarea.hasError:focus {
|
||||
outline: 2px solid var(--danger-soft);
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
.ui-textarea.disabled {
|
||||
background: var(--surface-subtle);
|
||||
opacity: 0.65;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,15 @@
|
||||
export { default as UiButton } from './UiButton.vue';
|
||||
export { default as UiInput } from './UiInput.vue';
|
||||
export { default as UiTextarea } from './UiTextarea.vue';
|
||||
export { default as UiSelect } from './UiSelect.vue';
|
||||
export { default as UiField } from './UiField.vue';
|
||||
export { default as UiCard } from './UiCard.vue';
|
||||
export { default as UiModal } from './UiModal.vue';
|
||||
export { default as UiPageHeader } from './UiPageHeader.vue';
|
||||
export { default as UiBadge } from './UiBadge.vue';
|
||||
export { default as UiPagination } from './UiPagination.vue';
|
||||
export { default as UiEmptyState } from './UiEmptyState.vue';
|
||||
export { default as UiSearch } from './UiSearch.vue';
|
||||
export { default as UiFilterBar } from './UiFilterBar.vue';
|
||||
export { default as UiSegmented } from './UiSegmented.vue';
|
||||
export { default as UiChart, CHART_PALETTE, type UiChartOption } from './UiChart.vue';
|
||||
Reference in New Issue
Block a user