feat(webui): Soft Ink universal component set

Overlays: ab-modal (adaptive dialog/sheet on headlessui, focus trap,
footer slot), promise-based useConfirm + host, ab-tooltip. Forms:
ab-field (label/description/error/aria wiring), ab-input component
(affixes, clearable, password reveal), ab-select/ab-switch wrappers,
ab-segmented underline tabs; ab-setting rebuilt on them with new
description/error/required props. Display: ab-tag v2 (semantic marks,
ink text, no truncation), ab-status v2 (4 states + label/detail),
ab-badge, ab-progress (scaleX fill), ab-container footer slot,
accessible animated ab-fold-panel. Feedback: ab-alert (hairline, bold
lead word), inverted-ink toast theme, ab-empty, ab-skeleton, ab-list
(selection, loading, density), ab-toolbar. All with vitest coverage
and Storybook stories; bottom sheet gains footer slot and drops
hard-coded overlay/z-index.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FAxVyRwrY7z5NotTtgnwVs
This commit is contained in:
Estrella Pan
2026-07-06 11:15:16 +02:00
parent 4051bea609
commit 6e5cb2ca07
50 changed files with 2729 additions and 164 deletions

View File

@@ -147,6 +147,7 @@ const naiveTheme = computed(() => (isDark.value ? darkTheme : null));
<NMessageProvider>
<NDialogProvider>
<RouterView></RouterView>
<ab-confirm-host />
</NDialogProvider>
</NMessageProvider>
</NConfigProvider>

View File

@@ -2,9 +2,12 @@
withDefaults(
defineProps<{
title: string;
/** 去掉 body 内边距(内容自带布局时,如列表/表格贴边) */
flush?: boolean;
}>(),
{
title: 'title',
flush: false,
}
);
</script>
@@ -16,9 +19,13 @@ withDefaults(
<slot name="title-right"></slot>
</div>
<div class="container-body">
<div class="container-body" :class="{ 'container-body--flush': flush }">
<slot></slot>
</div>
<div v-if="$slots.footer" class="container-footer">
<slot name="footer"></slot>
</div>
</div>
</template>
@@ -57,5 +64,18 @@ withDefaults(
font-size: 14px;
transition: background-color var(--transition-normal),
color var(--transition-normal);
&--flush {
padding: 0;
}
}
.container-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 10px 14px;
border-top: 1px solid var(--color-border);
background: var(--color-surface);
}
</style>

View File

@@ -1,7 +1,9 @@
<script lang="ts" setup>
import { Down, Up } from '@icon-park/vue-next';
import { Down } from '@icon-park/vue-next';
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue';
// 折叠面板 v2:headlessui 提供键盘/aria-expanded;
// 旋转箭头 + grid-rows 高度动画(reduced-motion 下直接切换)。
withDefaults(
defineProps<{
title: string;
@@ -17,16 +19,28 @@ withDefaults(
<template>
<Disclosure v-slot="{ open }" :default-open="defaultOpen">
<div class="fold-panel">
<DisclosureButton class="fold-panel-header">
<DisclosureButton
class="fold-panel-header"
:class="{ 'fold-panel-header--open': open }"
>
<div class="fold-panel-title">{{ title }}</div>
<Component :is="open ? Up : Down" :size="14" />
<slot name="extra"></slot>
<Down
class="fold-panel-chevron"
:class="{ 'fold-panel-chevron--open': open }"
:size="14"
/>
</DisclosureButton>
<DisclosurePanel>
<div class="fold-panel-body">
<slot></slot>
<div class="fold-panel-collapse" :class="{ 'is-open': open }">
<div class="fold-panel-clip">
<DisclosurePanel static>
<div class="fold-panel-body">
<slot></slot>
</div>
</DisclosurePanel>
</div>
</DisclosurePanel>
</div>
</div>
</Disclosure>
</template>
@@ -38,33 +52,80 @@ withDefaults(
transition: border-color var(--transition-normal);
min-width: 0; // Allow panel to shrink below content size
max-width: 100%;
overflow: hidden;
}
.fold-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
padding: 0 14px;
height: 34px;
background: transparent;
color: var(--color-text-secondary);
border: none;
border-bottom: 1px solid var(--color-border);
border-bottom: 1px solid transparent;
cursor: pointer;
transition: color var(--transition-normal),
border-color var(--transition-normal);
@include forTouch {
height: var(--touch-target);
}
cursor: pointer;
transition: color var(--transition-normal),
border-color var(--transition-normal);
&:hover {
color: var(--color-text);
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
border-radius: var(--radius-sm);
}
&--open {
border-bottom-color: var(--color-border);
}
}
.fold-panel-title {
flex: 1;
text-align: left;
font-size: 15px;
font-weight: 600;
}
.fold-panel-chevron {
flex-shrink: 0;
transition: transform var(--transition-normal);
&--open {
transform: rotate(180deg);
}
}
// grid-rows 高度动画:0fr ↔ 1fr(不动画 height 本身)
.fold-panel-collapse {
display: grid;
grid-template-rows: 0fr;
visibility: hidden;
transition: grid-template-rows var(--transition-normal),
visibility 0s var(--transition-normal);
&.is-open {
grid-template-rows: 1fr;
visibility: visible;
transition: grid-template-rows var(--transition-normal), visibility 0s;
}
}
.fold-panel-clip {
min-height: 0;
overflow: hidden;
}
.fold-panel-body {
background: var(--color-surface);
padding: 12px 14px;
@@ -74,4 +135,11 @@ withDefaults(
transition: background-color var(--transition-normal),
color var(--transition-normal);
}
@media (prefers-reduced-motion: reduce) {
.fold-panel-collapse,
.fold-panel-chevron {
transition: none;
}
}
</style>

View File

@@ -1,40 +1,29 @@
<script lang="ts" setup>
import { useId } from 'vue';
import { NDynamicTags, NSelect, NSwitch } from 'naive-ui';
import { NDynamicTags } from 'naive-ui';
import { isObject } from 'radash';
import type { SelectOption } from 'naive-ui';
import AbField from './basic/ab-field.vue';
import AbInput from './basic/ab-input.vue';
import AbSelect from './basic/ab-select.vue';
import AbSwitch from './basic/ab-switch.vue';
import type { AbSettingProps, SelectItem } from '#/components';
// 设置行:ab-field(标签/说明/错误/aria)+ 对应控件的组合。
const props = withDefaults(defineProps<AbSettingProps>(), {
css: '',
bottomLine: false,
description: '',
error: '',
required: false,
});
const data = defineModel<any>('data');
// 每个设置项一对稳定 id:原生 input 用 <label for>,
// naive-ui 组件(无法透传 id 到焦点元素)用 aria-label/aria-labelledby
const controlId = useId();
const labelId = useId();
const labelText = computed(() =>
typeof props.label === 'function' ? props.label() : props.label
);
// 旧 AbSelect 接受 items: Array<SelectItem | string>;NSelect 需要
// options: Array<{ label, value }>,这里做一层适配
const selectOptions = computed<SelectOption[]>(() => {
const items: Array<SelectItem | string> = props.prop?.items ?? [];
return items.map((item) =>
isObject(item)
? { label: item.label ?? item.value, value: item.value }
: { label: item, value: item }
);
});
// 旧组件允许 v-model 绑定对象(SelectItem);NSelect 只接受基础类型,
// 读取时取 .value,写回时保持字符串(配置值均为字符串)
// 旧组件允许 v-model 绑定对象(SelectItem);读取时取 .value,
// 写回时保持字符串(配置值均为字符串)
const selectValue = computed<string | number | null>({
get() {
return isObject(data.value) ? (data.value as SelectItem).value : data.value;
@@ -47,36 +36,37 @@ const selectValue = computed<string | number | null>({
<template>
<div class="setting-item">
<ab-label
<AbField
:label="label"
:for-id="type === 'input' ? controlId : undefined"
:label-id="labelId"
:description="description"
:error="error"
:required="required"
>
<NSwitch
<AbSwitch
v-if="type === 'switch'"
v-model:value="data"
v-model="data"
:class="css"
:aria-label="labelText"
:aria-labelledby="labelId"
></NSwitch>
></AbSwitch>
<NSelect
<AbSelect
v-else-if="type === 'select'"
v-model:value="selectValue"
:options="selectOptions"
v-model="selectValue"
:items="prop?.items ?? []"
:class="css"
class="setting-select"
:aria-label="labelText"
></NSelect>
></AbSelect>
<input
<AbInput
v-else-if="type === 'input'"
:id="controlId"
v-model="data"
ab-input
:class="css"
v-bind="prop"
/>
:type="prop?.type"
:placeholder="prop?.placeholder"
:min="prop?.min"
:max="prop?.max"
></AbInput>
<div v-else-if="type === 'dynamic-tags'" class="dynamic-tags-wrapper">
<NDynamicTags
@@ -85,7 +75,7 @@ const selectValue = computed<string | number | null>({
:aria-label="labelText"
></NDynamicTags>
</div>
</ab-label>
</AbField>
<div v-if="bottomLine" class="setting-divider"></div>
</div>
@@ -104,7 +94,8 @@ const selectValue = computed<string | number | null>({
:deep(input),
:deep(select),
:deep(.n-select),
:deep(.n-input) {
:deep(.n-input),
:deep(.ab-input) {
max-width: 100%;
}
}

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup>
import { Down, Right } from '@icon-park/vue-next';
import { Down } from '@icon-park/vue-next';
/** v-model open state */
const open = defineModel<boolean>('open', { default: false });
@@ -7,8 +7,18 @@ const open = defineModel<boolean>('open', { default: false });
<template>
<div class="advanced-section">
<button class="advanced-toggle" @click="open = !open">
<component :is="open ? Down : Right" theme="outline" size="14" />
<button
type="button"
class="advanced-toggle"
:aria-expanded="open"
@click="open = !open"
>
<Down
class="advanced-chevron"
:class="{ 'advanced-chevron--open': open }"
theme="outline"
size="14"
/>
{{ $t('search.confirm.advanced') }}
</button>
@@ -42,6 +52,25 @@ const open = defineModel<boolean>('open', { default: false });
&:hover {
color: var(--color-text);
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
border-radius: var(--radius-sm);
}
}
.advanced-chevron {
transform: rotate(-90deg);
transition: transform var(--transition-normal);
&--open {
transform: rotate(0deg);
}
@media (prefers-reduced-motion: reduce) {
transition: none;
}
}
.advanced-content {

View File

@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest';
import { mount } from '@vue/test-utils';
import AbConfirmHost from '../ab-confirm-host.vue';
import { useConfirm } from '@/hooks/useConfirm';
async function tick() {
await new Promise((resolve) => setTimeout(resolve));
}
describe('useConfirm + ab-confirm-host', () => {
it('resolves true when the confirm button is clicked', async () => {
const wrapper = mount(AbConfirmHost, { attachTo: document.body });
const { confirm } = useConfirm();
const result = confirm({ title: 'Delete rule?', body: 'Files stay.' });
await tick();
expect(document.body.textContent).toContain('Delete rule?');
(
document.querySelector('.ab-confirm-ok') as HTMLButtonElement
).click();
await expect(result).resolves.toBe(true);
wrapper.unmount();
});
it('resolves false when cancelled', async () => {
const wrapper = mount(AbConfirmHost, { attachTo: document.body });
const { confirm } = useConfirm();
const result = confirm({ title: 'Reset log?' });
await tick();
(
document.querySelector('.ab-confirm-cancel') as HTMLButtonElement
).click();
await expect(result).resolves.toBe(false);
wrapper.unmount();
});
it('renders danger variant confirm button when danger is set', async () => {
const wrapper = mount(AbConfirmHost, { attachTo: document.body });
const { confirm } = useConfirm();
const result = confirm({ title: 'Delete rule?', danger: true });
await tick();
const ok = document.querySelector('.ab-confirm-ok') as HTMLButtonElement;
expect(ok.className).toContain('ab-btn--danger');
ok.click();
await result;
wrapper.unmount();
});
});

View File

@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest';
import { defineComponent, h, inject } from 'vue';
import { mount } from '@vue/test-utils';
import AbField, { abFieldInjectionKey } from '../ab-field.vue';
// A control that reads the field context the way ab-input/ab-select do
const FakeControl = defineComponent({
setup() {
const field = inject(abFieldInjectionKey, null);
return () =>
h('input', {
id: field?.controlId,
'aria-describedby': field?.describedBy.value,
'aria-invalid': field?.invalid.value || undefined,
});
},
});
function mountField(props = {}) {
return mount(AbField, {
props: { label: 'RSS link', ...props },
slots: { default: FakeControl },
});
}
describe('ab-field', () => {
it('associates the label with the control via for/id', () => {
const wrapper = mountField();
const label = wrapper.find('label');
const input = wrapper.find('input');
expect(label.attributes('for')).toBeTruthy();
expect(label.attributes('for')).toBe(input.attributes('id'));
});
it('shows a required marker when required', () => {
const wrapper = mountField({ required: true });
expect(wrapper.find('.ab-field-required').exists()).toBe(true);
});
it('wires description and error into aria-describedby', () => {
const wrapper = mountField({
description: 'Mikan personal feed.',
error: 'Must be a URL',
});
const described = wrapper.find('input').attributes('aria-describedby');
expect(described).toBeTruthy();
for (const id of described!.split(' ')) {
expect(wrapper.find(`#${id}`).exists()).toBe(true);
}
expect(wrapper.text()).toContain('Mikan personal feed.');
expect(wrapper.text()).toContain('Must be a URL');
});
it('marks the control invalid when error is set', () => {
const wrapper = mountField({ error: 'Must be a URL' });
expect(wrapper.find('input').attributes('aria-invalid')).toBe('true');
});
});

View File

@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest';
import { mount } from '@vue/test-utils';
import AbInput from '../ab-input.vue';
describe('ab-input', () => {
it('binds v-model', async () => {
const wrapper = mount(AbInput, {
props: {
modelValue: 'abc',
'onUpdate:modelValue': (v: string) =>
wrapper.setProps({ modelValue: v }),
},
});
await wrapper.find('input').setValue('https://mikanani.me');
expect(wrapper.props('modelValue')).toBe('https://mikanani.me');
});
it('applies the error class when error is set', () => {
const wrapper = mount(AbInput, {
props: { modelValue: '', error: true },
});
expect(wrapper.classes()).toContain('ab-input--error');
});
it('clears the value via the clear button when clearable', async () => {
const wrapper = mount(AbInput, {
props: {
modelValue: 'abc',
clearable: true,
'onUpdate:modelValue': (v: string) =>
wrapper.setProps({ modelValue: v }),
},
});
await wrapper.find('.ab-input-clear').trigger('click');
expect(wrapper.props('modelValue')).toBe('');
});
it('toggles password visibility', async () => {
const wrapper = mount(AbInput, {
props: { modelValue: 'secret', type: 'password' },
});
expect(wrapper.find('input').attributes('type')).toBe('password');
await wrapper.find('.ab-input-reveal').trigger('click');
expect(wrapper.find('input').attributes('type')).toBe('text');
});
it('emits numbers for type=number', async () => {
const wrapper = mount(AbInput, {
props: { modelValue: 0, type: 'number' },
});
await wrapper.find('input').setValue('42');
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([42]);
});
});

View File

@@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest';
import { mount } from '@vue/test-utils';
import AbList from '../ab-list.vue';
const items = [
{ id: 1, name: '葬送的芙莉莲 第二季' },
{ id: 2, name: 'Re:从零开始的异世界生活' },
{ id: 3, name: '怪兽8号' },
];
describe('ab-list', () => {
it('renders one row per item via the row slot', () => {
const wrapper = mount(AbList, {
props: { items },
slots: { row: `<template #row="{ item }">{{ item.name }}</template>` },
});
expect(wrapper.findAll('.ab-list-row')).toHaveLength(3);
expect(wrapper.text()).toContain('怪兽8号');
});
it('emits selection updates when a checkbox is toggled', async () => {
const wrapper = mount(AbList, {
props: { items, selectable: true, selected: [] },
});
const boxes = wrapper.findAll('input[type="checkbox"]');
// 第一个是全选,其后每行一个
await boxes[1].setValue(true);
expect(wrapper.emitted('update:selected')?.at(-1)).toEqual([[1]]);
});
it('select-all toggles every row', async () => {
const wrapper = mount(AbList, {
props: { items, selectable: true, selected: [] },
});
await wrapper.find('.ab-list-selectall input').setValue(true);
expect(wrapper.emitted('update:selected')?.at(-1)).toEqual([[1, 2, 3]]);
});
it('shows skeleton rows while loading', () => {
const wrapper = mount(AbList, {
props: { items: [], loading: true },
});
expect(wrapper.find('.ab-skeleton').exists()).toBe(true);
});
it('shows the empty slot when there are no items', () => {
const wrapper = mount(AbList, {
props: { items: [] },
slots: { empty: '<p id="empty-msg">nothing</p>' },
});
expect(wrapper.find('#empty-msg').exists()).toBe(true);
});
it('emits row-click with the item', async () => {
const wrapper = mount(AbList, { props: { items } });
await wrapper.findAll('.ab-list-row')[2].trigger('click');
expect(wrapper.emitted('row-click')?.[0]?.[0]).toMatchObject({ id: 3 });
});
});

View File

@@ -2,11 +2,13 @@ import { describe, expect, it, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import AbMenu from '../ab-menu.vue';
const items = (handler = vi.fn()) => [
{ key: 'enable', label: 'Enable rule', handler },
{ key: 'archive', label: () => 'Archive rule' },
{ key: 'delete', label: 'Delete rule', danger: true },
];
function items(handler = vi.fn()) {
return [
{ key: 'enable', label: 'Enable rule', handler },
{ key: 'archive', label: () => 'Archive rule' },
{ key: 'delete', label: 'Delete rule', danger: true },
];
}
describe('ab-menu', () => {
it('renders item labels (string and function) after opening', async () => {

View File

@@ -0,0 +1,69 @@
import { afterEach, describe, expect, it } from 'vitest';
import { mount } from '@vue/test-utils';
import AbModal from '../ab-modal.vue';
// headlessui teleports dialogs into document.body and its leave transition
// never completes under happy-dom — wipe the body between tests.
afterEach(() => {
document.body.innerHTML = '';
});
// matchMedia is mocked to matches:false in setup.ts → desktop dialog branch
async function mountModal(props = {}, slots = {}) {
const wrapper = mount(AbModal, {
props: { show: true, title: 'Delete rule?', ...props },
slots: { default: '<p>body text</p>', ...slots },
attachTo: document.body,
});
// headlessui mounts dialog content on the next tick (transition setup)
await new Promise((resolve) => setTimeout(resolve));
return wrapper;
}
describe('ab-modal', () => {
it('renders title and body when shown', async () => {
const wrapper = await mountModal();
expect(document.body.textContent).toContain('Delete rule?');
expect(document.body.textContent).toContain('body text');
wrapper.unmount();
});
it('renders nothing when show is false', async () => {
const wrapper = await mountModal({ show: false });
expect(document.body.textContent).not.toContain('Delete rule?');
wrapper.unmount();
});
it('exposes dialog semantics', async () => {
const wrapper = await mountModal();
expect(document.querySelector('[role="dialog"]')).not.toBeNull();
wrapper.unmount();
});
it('renders the footer slot', async () => {
const wrapper = await mountModal(
{},
{ footer: '<button id="ok">OK</button>' }
);
expect(document.querySelector('#ok')).not.toBeNull();
wrapper.unmount();
});
it('emits update:show false when the close button is clicked', async () => {
const wrapper = await mountModal();
const close = document.querySelector(
'.ab-modal-close'
) as HTMLButtonElement;
expect(close).not.toBeNull();
close.click();
await new Promise((resolve) => setTimeout(resolve));
expect(wrapper.emitted('update:show')?.at(-1)).toEqual([false]);
wrapper.unmount();
});
it('hides the close button when closable is false', async () => {
const wrapper = await mountModal({ closable: false });
expect(document.querySelector('.ab-modal-close')).toBeNull();
wrapper.unmount();
});
});

View File

@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest';
import { mount } from '@vue/test-utils';
import AbSegmented from '../ab-segmented.vue';
const options = [
{ label: 'All', value: 'all' },
{ label: 'Airing', value: 'airing' },
{ label: 'Archived', value: 'archived' },
];
describe('ab-segmented', () => {
it('marks the selected option', () => {
const wrapper = mount(AbSegmented, {
props: { options, value: 'airing' },
});
const tabs = wrapper.findAll('[role="tab"]');
expect(tabs[1].attributes('aria-selected')).toBe('true');
expect(tabs[0].attributes('aria-selected')).toBe('false');
});
it('updates v-model on click', async () => {
const wrapper = mount(AbSegmented, {
props: { options, value: 'all' },
});
await wrapper.findAll('[role="tab"]')[2].trigger('click');
expect(wrapper.emitted('update:value')?.[0]).toEqual(['archived']);
});
it('moves selection with arrow keys', async () => {
const wrapper = mount(AbSegmented, {
props: { options, value: 'all' },
});
await wrapper.findAll('[role="tab"]')[0].trigger('keydown', {
key: 'ArrowRight',
});
expect(wrapper.emitted('update:value')?.[0]).toEqual(['airing']);
});
});

View File

@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import { mount } from '@vue/test-utils';
import AbTag from '../ab-tag.vue';
describe('ab-tag', () => {
it('renders the title text', () => {
const wrapper = mount(AbTag, {
props: { type: 'success', title: '葬送的芙莉莲 第二季' },
});
expect(wrapper.text()).toContain('葬送的芙莉莲 第二季');
});
it('applies the semantic type class', () => {
const wrapper = mount(AbTag, {
props: { type: 'warning', title: 'Needs review' },
});
expect(wrapper.classes()).toContain('ab-tag--warning');
});
it('does not render a close control by default', () => {
const wrapper = mount(AbTag, { props: { type: 'info', title: 'S02E08' } });
expect(wrapper.find('.ab-tag-close').exists()).toBe(false);
});
it('emits close when the close control is clicked', async () => {
const wrapper = mount(AbTag, {
props: { type: 'info', title: 'S02E08', closable: true },
});
await wrapper.find('.ab-tag-close').trigger('click');
expect(wrapper.emitted('close')).toHaveLength(1);
});
});

View File

@@ -0,0 +1,120 @@
<script lang="ts" setup>
// Soft Ink 行内提示:透明底 + 细线框,语义由加粗的彩色引导词承载 —
// 不用图标、不用彩色填充(拒绝 AI 模板味的着色警告框)。
withDefaults(
defineProps<{
type?: 'info' | 'warning' | 'danger';
/** 加粗引导词,如 “Feed unreachable.”(语义色只落在这里) */
title?: string;
closable?: boolean;
}>(),
{
type: 'info',
title: '',
closable: false,
}
);
defineEmits<{ close: [] }>();
</script>
<template>
<div
class="ab-alert"
:class="`ab-alert--${type}`"
:role="type === 'info' ? 'status' : 'alert'"
>
<p class="ab-alert-text">
<b v-if="title" class="ab-alert-title">{{ title }}</b>
<slot></slot>
</p>
<span v-if="$slots.action" class="ab-alert-action">
<slot name="action"></slot>
</span>
<button
v-if="closable"
type="button"
class="ab-alert-close"
:aria-label="$t('common.cancel')"
@click="$emit('close')"
>
<svg viewBox="0 0 24 24" fill="none" width="11" height="11">
<path
d="M6 6l12 12M18 6 6 18"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
/>
</svg>
</button>
</div>
</template>
<style lang="scss" scoped>
.ab-alert {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px 13px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: transparent;
font-size: 13px;
color: var(--color-text);
}
.ab-alert-text {
flex: 1;
margin: 0;
min-width: 0;
}
.ab-alert-title {
font-weight: 650;
margin-right: 4px;
.ab-alert--info & {
color: var(--color-primary);
}
.ab-alert--warning & {
color: var(--color-warning);
}
.ab-alert--danger & {
color: var(--color-danger);
}
}
.ab-alert-action {
flex-shrink: 0;
}
.ab-alert-close {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 22px;
height: 22px;
margin: -2px -4px 0 0;
padding: 0;
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text-muted);
cursor: pointer;
transition: color var(--transition-fast);
&:hover {
color: var(--color-text);
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 1px;
}
}
</style>

View File

@@ -0,0 +1,57 @@
<script lang="ts" setup>
// 计数/圆点徽标(通知中心等):溢出显示 max+,count 为 0 时隐藏。
const props = withDefaults(
defineProps<{
count?: number;
/** 只显示小圆点(不显示数字) */
dot?: boolean;
max?: number;
}>(),
{
count: 0,
dot: false,
max: 99,
}
);
const visible = computed(() => props.dot || props.count > 0);
const text = computed(() =>
props.count > props.max ? `${props.max}+` : String(props.count)
);
</script>
<template>
<span
v-if="visible"
class="ab-badge"
:class="{ 'ab-badge--dot': dot }"
aria-hidden="true"
>
<template v-if="!dot">{{ text }}</template>
</span>
</template>
<style lang="scss" scoped>
.ab-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 17px;
height: 17px;
padding: 0 5px;
border-radius: var(--radius-full);
background: var(--color-danger);
color: var(--color-white);
font-size: 11px;
font-weight: 600;
line-height: 1;
font-variant-numeric: tabular-nums;
&--dot {
min-width: 8px;
width: 8px;
height: 8px;
padding: 0;
}
}
</style>

View File

@@ -146,6 +146,11 @@ function close() {
<div class="ab-bottom-sheet__content">
<slot />
</div>
<!-- Footer actions -->
<div v-if="$slots.footer" class="ab-bottom-sheet__footer">
<slot name="footer" />
</div>
</DialogPanel>
</div>
</TransitionChild>
@@ -157,22 +162,22 @@ function close() {
.ab-bottom-sheet {
position: fixed;
inset: 0;
z-index: 100;
z-index: var(--z-modal);
display: flex;
align-items: flex-end;
&__backdrop {
position: fixed;
inset: 0;
z-index: 100;
background: rgba(0, 0, 0, 0.4);
z-index: var(--z-modal-backdrop);
background: var(--color-overlay);
backdrop-filter: blur(4px);
}
&__container {
position: fixed;
inset: 0;
z-index: 101;
z-index: var(--z-modal);
display: flex;
align-items: flex-end;
justify-content: center;
@@ -181,7 +186,6 @@ function close() {
&__panel {
position: relative;
z-index: 102;
width: 100%;
max-width: 640px;
max-height: 85dvh; // Use dynamic viewport height for iOS Safari keyboard support
@@ -244,5 +248,13 @@ function close() {
scroll-margin-bottom: 20px;
}
}
&__footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 20px;
border-top: 1px solid var(--color-border);
}
}
</style>

View File

@@ -0,0 +1,50 @@
<script lang="ts" setup>
import { computed } from 'vue';
import AbButton from './ab-button.vue';
import AbModal from './ab-modal.vue';
import { useConfirmState } from '@/hooks/useConfirm';
// 全局确认对话框宿主:挂载一次(App.vue),由 useConfirm() 驱动。
const { state, settle } = useConfirmState();
const show = computed({
get: () => state.current !== null,
set: (value: boolean) => {
if (!value) settle(false);
},
});
</script>
<template>
<AbModal
v-model:show="show"
size="sm"
:title="state.current?.title ?? ''"
>
<p v-if="state.current?.body" class="ab-confirm-body">
{{ state.current.body }}
</p>
<template #footer>
<AbButton class="ab-confirm-cancel" size="sm" @click="settle(false)">
{{ state.current?.cancelText ?? $t('common.cancel') }}
</AbButton>
<AbButton
class="ab-confirm-ok"
size="sm"
:variant="state.current?.danger ? 'danger' : 'primary'"
@click="settle(true)"
>
{{ state.current?.confirmText ?? $t('common.confirm') }}
</AbButton>
</template>
</AbModal>
</template>
<style lang="scss" scoped>
.ab-confirm-body {
margin: 0;
font-size: 13.5px;
color: var(--color-text-secondary);
}
</style>

View File

@@ -0,0 +1,53 @@
<script lang="ts" setup>
// Soft Ink 空状态:左对齐的说明文字 + 一个下一步动作 —
// 教用户界面怎么用,而不是居中图标块 + “暂无数据”。
withDefaults(
defineProps<{
title: string;
description?: string;
}>(),
{
description: '',
}
);
</script>
<template>
<div class="ab-empty">
<p class="ab-empty-title">{{ title }}</p>
<p v-if="description" class="ab-empty-description">{{ description }}</p>
<div v-if="$slots.action" class="ab-empty-action">
<slot name="action"></slot>
</div>
</div>
</template>
<style lang="scss" scoped>
.ab-empty {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
padding: 18px;
border: 1px dashed var(--color-border-hover);
border-radius: var(--radius-md);
}
.ab-empty-title {
margin: 0;
font-size: 14.5px;
font-weight: 600;
color: var(--color-text);
}
.ab-empty-description {
margin: 0;
font-size: 13px;
color: var(--color-text-secondary);
max-width: 52ch;
}
.ab-empty-action {
margin-top: 6px;
}
</style>

View File

@@ -0,0 +1,32 @@
import type { Meta, StoryObj } from '@storybook/vue3';
import AbField from './ab-field.vue';
import AbInput from './ab-input.vue';
const meta: Meta<typeof AbField> = {
title: 'basic/ab-field',
component: AbField,
tags: ['autodocs'],
};
export default meta;
type Story = StoryObj<typeof AbField>;
export const Template: Story = {
render: () => ({
components: { AbField, AbInput },
template: `
<div style="display:flex;flex-direction:column;gap:20px;max-width:520px">
<ab-field
label="RSS link"
description="Mikan personal feed. Token stays on this machine."
required
>
<ab-input model-value="https://mikanani.me/RSS/MyBangumi" />
</ab-field>
<ab-field label="Rename offset" error="Must be an integer, e.g. -12">
<ab-input model-value="abc" />
</ab-field>
</div>`,
}),
};

View File

@@ -0,0 +1,155 @@
<script lang="ts">
import { computed, provide, useId } from 'vue';
import type { ComputedRef, InjectionKey } from 'vue';
export interface AbFieldContext {
/** 原生控件应设置的 id(配合 <label for>) */
controlId: string;
/** 标签文本节点 id(无法用 for 关联的组件用 aria-labelledby) */
labelId: string;
/** description/error 节点 id 列表,控件应设为 aria-describedby */
describedBy: ComputedRef<string | undefined>;
/** 当前是否处于错误态(控件应设 aria-invalid 并显示错误样式) */
invalid: ComputedRef<boolean>;
}
export const abFieldInjectionKey: InjectionKey<AbFieldContext> =
Symbol('ab-field');
</script>
<script lang="ts" setup>
// 表单字段容器(取代 ab-label):label + description + error + aria 接线。
// 控件放默认插槽;ab-input 等会自动消费注入的 id/aria 上下文。
const props = withDefaults(
defineProps<{
label: string | (() => string);
description?: string;
error?: string;
required?: boolean;
}>(),
{
description: '',
error: '',
required: false,
}
);
// Vue 类型将 useId 标为可空(组件实例外);setup 内恒为 string
const controlId = useId() as string;
const labelId = useId() as string;
const descriptionId = useId() as string;
const errorId = useId() as string;
const labelText = computed(() =>
typeof props.label === 'function' ? props.label() : props.label
);
const describedBy = computed(() => {
const ids: string[] = [];
if (props.description) ids.push(descriptionId);
if (props.error) ids.push(errorId);
return ids.length > 0 ? ids.join(' ') : undefined;
});
const invalid = computed(() => Boolean(props.error));
provide(abFieldInjectionKey, { controlId, labelId, describedBy, invalid });
</script>
<template>
<div class="ab-field" :class="{ 'ab-field--invalid': invalid }">
<div class="ab-field-main">
<div class="ab-field-heading">
<label :id="labelId" class="ab-field-label" :for="controlId">
{{ labelText }}
<span
v-if="required"
class="ab-field-required"
aria-hidden="true"
>*</span
>
</label>
<p v-if="description" :id="descriptionId" class="ab-field-description">
{{ description }}
</p>
</div>
<div class="ab-field-control">
<slot></slot>
</div>
</div>
<p v-if="error" :id="errorId" class="ab-field-error" role="alert">
{{ error }}
</p>
</div>
</template>
<style lang="scss" scoped>
.ab-field {
display: flex;
flex-direction: column;
gap: 5px;
width: 100%;
}
.ab-field-main {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
min-height: 32px;
@include forTablet {
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 12px;
}
}
.ab-field-heading {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.ab-field-label {
font-size: 14px;
color: var(--color-text);
transition: color var(--transition-normal);
}
.ab-field-required {
color: var(--color-danger);
}
.ab-field-description {
margin: 0;
font-size: 12.5px;
color: var(--color-text-secondary);
max-width: 46ch;
}
.ab-field-control {
flex-shrink: 0;
// 移动端控件占满整行(沿用 ab-label 的行为)
width: 100%;
@include forTablet {
width: auto;
}
}
.ab-field-error {
margin: 0;
font-size: 12.5px;
color: var(--color-danger);
@include forTablet {
text-align: right;
}
}
</style>

View File

@@ -5,10 +5,15 @@ const props = withDefaults(
/** 无文本按钮的可访问名称(必填) */
label: string;
size?: 'sm' | 'md';
/** solid:主色填充(用于卡片上的主要动作);round:圆形 */
variant?: 'ghost' | 'solid';
round?: boolean;
disabled?: boolean;
}>(),
{
size: 'md',
variant: 'ghost',
round: false,
disabled: false,
}
);
@@ -24,7 +29,11 @@ function onClick(event: MouseEvent) {
<template>
<button
class="ab-icon-btn"
:class="`ab-icon-btn--${size}`"
:class="[
`ab-icon-btn--${size}`,
`ab-icon-btn--${variant}`,
round && 'ab-icon-btn--round',
]"
type="button"
:aria-label="label"
:title="label"
@@ -85,5 +94,19 @@ function onClick(event: MouseEvent) {
height: 36px;
}
}
&--solid {
background: var(--color-primary);
color: var(--color-white);
&:hover:not(:disabled) {
background: var(--color-primary-hover);
color: var(--color-white);
}
}
&--round {
border-radius: var(--radius-full);
}
}
</style>

View File

@@ -0,0 +1,32 @@
import type { Meta, StoryObj } from '@storybook/vue3';
import { ref } from 'vue';
import AbInput from './ab-input.vue';
const meta: Meta<typeof AbInput> = {
title: 'basic/ab-input',
component: AbInput,
tags: ['autodocs'],
};
export default meta;
type Story = StoryObj<typeof AbInput>;
export const Template: Story = {
render: () => ({
components: { AbInput },
setup() {
const text = ref('https://mikanani.me/RSS/MyBangumi');
const secret = ref('token-123');
const broken = ref('abc');
return { text, secret, broken };
},
template: `
<div style="display:flex;flex-direction:column;gap:14px;max-width:320px">
<ab-input v-model="text" clearable placeholder="RSS link" />
<ab-input v-model="secret" type="password" />
<ab-input v-model="broken" error />
<ab-input model-value="disabled" disabled />
</div>`,
}),
};

View File

@@ -0,0 +1,240 @@
<script lang="ts" setup>
import { computed, inject, ref } from 'vue';
import { abFieldInjectionKey } from './ab-field.vue';
// 文本输入组件(取代 UnoCSS 的 ab-input 快捷类):
// Soft Ink 填充式,左对齐;支持前后缀、清空、密码可见切换、错误态。
const props = withDefaults(
defineProps<{
type?: 'text' | 'password' | 'number' | 'url' | 'search';
placeholder?: string;
disabled?: boolean;
clearable?: boolean;
/** 错误态样式;错误文案由外层 ab-field 显示 */
error?: boolean;
ariaLabel?: string;
min?: number;
max?: number;
step?: number;
}>(),
{
type: 'text',
placeholder: '',
disabled: false,
clearable: false,
error: false,
ariaLabel: undefined,
min: undefined,
max: undefined,
step: undefined,
}
);
const model = defineModel<string | number>({ default: '' });
const field = inject(abFieldInjectionKey, null);
const revealed = ref(false);
const effectiveType = computed(() => {
if (props.type === 'password') return revealed.value ? 'text' : 'password';
return props.type;
});
const invalid = computed(() => props.error || field?.invalid.value === true);
function onInput(event: Event) {
const value = (event.target as HTMLInputElement).value;
model.value = props.type === 'number' && value !== '' ? Number(value) : value;
}
function clear() {
model.value = '';
}
</script>
<template>
<span
class="ab-input"
:class="{
'ab-input--error': invalid,
'ab-input--disabled': disabled,
}"
>
<span v-if="$slots.prefix" class="ab-input-affix">
<slot name="prefix"></slot>
</span>
<input
:id="field?.controlId"
:value="model"
:type="effectiveType"
:placeholder="placeholder"
:disabled="disabled"
:min="min"
:max="max"
:step="step"
:aria-label="ariaLabel"
:aria-describedby="field?.describedBy.value"
:aria-invalid="invalid || undefined"
@input="onInput"
/>
<button
v-if="clearable && String(model).length > 0 && !disabled"
type="button"
class="ab-input-clear"
:aria-label="$t('common.clear')"
tabindex="-1"
@click="clear"
>
<svg viewBox="0 0 24 24" fill="none" width="12" height="12">
<path
d="M6 6l12 12M18 6 6 18"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
/>
</svg>
</button>
<button
v-if="type === 'password'"
type="button"
class="ab-input-reveal"
:aria-pressed="revealed"
:aria-label="$t('common.toggle_password')"
@click="revealed = !revealed"
>
<svg
v-if="revealed"
viewBox="0 0 24 24"
fill="none"
width="14"
height="14"
>
<path
d="M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6-10-6-10-6Z"
stroke="currentColor"
stroke-width="2"
stroke-linejoin="round"
/>
<circle cx="12" cy="12" r="2.5" stroke="currentColor" stroke-width="2" />
</svg>
<svg v-else viewBox="0 0 24 24" fill="none" width="14" height="14">
<path
d="M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6-10-6-10-6Z"
stroke="currentColor"
stroke-width="2"
stroke-linejoin="round"
/>
<path
d="M4 4l16 16"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
</button>
<span v-if="$slots.suffix" class="ab-input-affix">
<slot name="suffix"></slot>
</span>
</span>
</template>
<style lang="scss" scoped>
.ab-input {
display: inline-flex;
align-items: center;
gap: 7px;
width: 100%;
height: 36px;
padding: 0 11px;
background: var(--color-surface-2);
border: 1px solid transparent;
border-radius: var(--radius-sm);
transition: border-color var(--transition-fast),
background-color var(--transition-fast), box-shadow var(--transition-fast);
@include forTablet {
width: 200px;
height: 30px;
}
&:focus-within {
background: var(--color-surface);
border-color: var(--color-primary);
box-shadow: 0 0 0 2px var(--color-primary-alpha);
}
&--error {
border-color: var(--color-danger);
&:focus-within {
border-color: var(--color-danger);
box-shadow: 0 0 0 2px
color-mix(in srgb, var(--color-danger) 20%, transparent);
}
}
&--disabled {
opacity: 0.55;
}
input {
flex: 1;
min-width: 0;
border: none;
outline: none;
background: transparent;
color: var(--color-text);
font-family: inherit;
font-size: 13px;
padding: 0;
&::placeholder {
color: var(--color-text-secondary);
opacity: 0.85;
}
&:disabled {
cursor: not-allowed;
}
}
}
.ab-input-affix {
display: inline-flex;
align-items: center;
flex-shrink: 0;
color: var(--color-text-muted);
font-size: 12px;
}
.ab-input-clear,
.ab-input-reveal {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 18px;
height: 18px;
padding: 0;
border: none;
border-radius: var(--radius-full);
background: transparent;
color: var(--color-text-muted);
cursor: pointer;
transition: color var(--transition-fast);
&:hover {
color: var(--color-text);
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 1px;
}
}
</style>

View File

@@ -0,0 +1,210 @@
<script lang="ts" setup>
import { computed } from 'vue';
import AbSkeleton from './ab-skeleton.vue';
import AbEmpty from './ab-empty.vue';
type ListItem = Record<string, any>;
// Soft Ink 列表(取代 ab-data-list):surface-2 填充行、悬停/聚焦态、
// 受控选择、加载骨架、i18n 空状态;density 供桌面数据页用紧凑行。
const props = withDefaults(
defineProps<{
items: ListItem[];
keyField?: string;
selectable?: boolean;
/** 受控选择:v-model:selected */
selected?: unknown[];
loading?: boolean;
density?: 'comfortable' | 'compact';
}>(),
{
keyField: 'id',
selectable: false,
selected: () => [],
loading: false,
density: 'comfortable',
}
);
const emit = defineEmits<{
'update:selected': [keys: unknown[]];
'row-click': [item: ListItem];
}>();
const selectedSet = computed(() => new Set(props.selected));
const allSelected = computed(
() => props.items.length > 0 && selectedSet.value.size === props.items.length
);
const someSelected = computed(
() => selectedSet.value.size > 0 && !allSelected.value
);
function toggle(key: unknown) {
const next = new Set(selectedSet.value);
if (next.has(key)) next.delete(key);
else next.add(key);
emit('update:selected', Array.from(next));
}
function toggleAll() {
if (allSelected.value) {
emit('update:selected', []);
} else {
emit(
'update:selected',
props.items.map((item) => item[props.keyField])
);
}
}
</script>
<template>
<div class="ab-list" :class="`ab-list--${density}`">
<!-- 全选头(可选择时) -->
<div v-if="selectable && items.length > 0" class="ab-list-header">
<label class="ab-list-selectall">
<input
type="checkbox"
:checked="allSelected"
:indeterminate="someSelected"
@change="toggleAll"
/>
<span>{{ $t('common.selectAll') }}</span>
</label>
<span class="ab-list-count"
>{{ items.length }} {{ $t('common.items') }}</span
>
</div>
<!-- 加载骨架 -->
<AbSkeleton v-if="loading" preset="row" :count="3" />
<!-- 行 -->
<template v-else>
<div
v-for="item in items"
:key="item[keyField]"
class="ab-list-row"
tabindex="0"
@click="emit('row-click', item)"
@keydown.enter="emit('row-click', item)"
>
<label
v-if="selectable"
class="ab-list-check"
@click.stop
@keydown.enter.stop
>
<input
type="checkbox"
:checked="selectedSet.has(item[keyField])"
:aria-label="$t('common.select')"
@change="toggle(item[keyField])"
/>
</label>
<div class="ab-list-row-content">
<slot name="row" :item="item">
{{ item[keyField] }}
</slot>
</div>
</div>
<!-- 空状态 -->
<div v-if="items.length === 0" class="ab-list-empty">
<slot name="empty">
<AbEmpty :title="$t('common.empty')"></AbEmpty>
</slot>
</div>
</template>
</div>
</template>
<style lang="scss" scoped>
.ab-list {
display: flex;
flex-direction: column;
gap: 6px;
width: 100%;
}
.ab-list-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 8px;
}
.ab-list-selectall {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--color-text-secondary);
cursor: pointer;
}
.ab-list-count {
font-size: 12px;
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
color: var(--color-text-muted);
}
.ab-list input[type='checkbox'] {
width: 16px;
height: 16px;
accent-color: var(--color-primary);
cursor: pointer;
}
.ab-list-row {
display: flex;
align-items: center;
gap: 12px;
padding: 11px 14px;
background: var(--color-surface-2);
border-radius: var(--radius-md);
cursor: pointer;
transition: background-color var(--transition-fast);
&:hover {
background: var(--color-surface-hover);
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 1px;
}
.ab-list--compact & {
padding: 7px 12px;
gap: 10px;
border-radius: var(--radius-sm);
}
}
.ab-list-check {
display: flex;
align-items: center;
flex-shrink: 0;
cursor: pointer;
// 扩大命中区
padding: 6px;
margin: -6px;
}
.ab-list-row-content {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 12px;
}
.ab-list-empty {
padding: 4px 0;
}
</style>

View File

@@ -0,0 +1,39 @@
import type { Meta, StoryObj } from '@storybook/vue3';
import { ref } from 'vue';
import AbButton from './ab-button.vue';
import AbModal from './ab-modal.vue';
const meta: Meta<typeof AbModal> = {
title: 'basic/ab-modal',
component: AbModal,
tags: ['autodocs'],
};
export default meta;
type Story = StoryObj<typeof AbModal>;
export const Template: Story = {
render: () => ({
components: { AbModal, AbButton },
setup() {
const show = ref(false);
return { show };
},
template: `
<div>
<ab-button variant="primary" @click="show = true">Open modal</ab-button>
<ab-modal v-model:show="show" title="Delete rule?" size="sm">
<p style="margin:0;color:var(--color-text-secondary)">
“葬送的芙莉莲 第二季” stops updating. Downloaded files stay on disk.
</p>
<template #footer>
<ab-button size="sm" @click="show = false">Cancel</ab-button>
<ab-button size="sm" variant="danger" @click="show = false">
Delete rule
</ab-button>
</template>
</ab-modal>
</div>`,
}),
};

View File

@@ -0,0 +1,197 @@
<script lang="ts" setup>
import {
Dialog,
DialogPanel,
DialogTitle,
TransitionChild,
TransitionRoot,
} from '@headlessui/vue';
import { Close } from '@icon-park/vue-next';
import AbBottomSheet from './ab-bottom-sheet.vue';
import AbIconButton from './ab-icon-button.vue';
import { useBreakpointQuery } from '@/hooks/useBreakpointQuery';
// 唯一的自适应弹窗组件(合并旧 ab-popup / ab-adaptive-modal):
// 桌面居中对话框,移动端底部抽屉。headlessui Dialog 提供焦点陷阱与 aria。
const props = withDefaults(
defineProps<{
title?: string;
size?: 'sm' | 'md' | 'lg';
/** false 时禁用遮罩点击/关闭按钮(用于流程中不可中断的弹窗) */
closable?: boolean;
maxHeight?: string;
}>(),
{
title: '',
size: 'md',
closable: true,
maxHeight: '85dvh',
}
);
const emit = defineEmits<{ close: [] }>();
const show = defineModel<boolean>('show', { default: false });
const { isMobile } = useBreakpointQuery();
function close() {
if (!props.closable) return;
show.value = false;
emit('close');
}
</script>
<template>
<!-- 移动端:底部抽屉 -->
<AbBottomSheet
v-if="isMobile"
:show="show"
:title="title"
:closeable="closable"
:max-height="maxHeight"
@update:show="show = $event"
@close="emit('close')"
>
<slot />
<template v-if="$slots.footer" #footer>
<slot name="footer" />
</template>
</AbBottomSheet>
<!-- 桌面/平板:居中对话框 -->
<TransitionRoot v-else appear :show="show" as="template">
<Dialog class="ab-modal" @close="close">
<TransitionChild
as="template"
enter="duration-200 ease-out"
enter-from="opacity-0"
enter-to="opacity-100"
leave="duration-150 ease-in"
leave-from="opacity-100"
leave-to="opacity-0"
>
<div class="ab-modal-backdrop" aria-hidden="true" />
</TransitionChild>
<div class="ab-modal-wrapper">
<TransitionChild
as="template"
enter="duration-200 ease-out"
enter-from="opacity-0 scale-97"
enter-to="opacity-100 scale-100"
leave="duration-150 ease-in"
leave-from="opacity-100 scale-100"
leave-to="opacity-0 scale-97"
>
<DialogPanel
class="ab-modal-panel"
:class="`ab-modal-panel--${size}`"
>
<header v-if="title || closable" class="ab-modal-header">
<DialogTitle as="h2" class="ab-modal-title">
{{ title }}
</DialogTitle>
<AbIconButton
v-if="closable"
class="ab-modal-close"
size="sm"
:label="$t('common.cancel')"
@click="close"
>
<Close theme="outline" size="16" />
</AbIconButton>
</header>
<div class="ab-modal-body" :style="{ maxHeight }">
<slot />
</div>
<footer v-if="$slots.footer" class="ab-modal-footer">
<slot name="footer" />
</footer>
</DialogPanel>
</TransitionChild>
</div>
</Dialog>
</TransitionRoot>
</template>
<style lang="scss" scoped>
.ab-modal {
position: relative;
z-index: var(--z-modal);
}
.ab-modal-backdrop {
position: fixed;
inset: 0;
z-index: var(--z-modal-backdrop);
background: var(--color-overlay);
}
.ab-modal-wrapper {
position: fixed;
inset: 0;
z-index: var(--z-modal);
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
overflow-y: auto;
}
.ab-modal-panel {
display: flex;
flex-direction: column;
width: 100%;
background: var(--color-surface);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
overflow: hidden;
&--sm {
max-width: 400px;
}
&--md {
max-width: 520px;
}
&--lg {
max-width: 680px;
}
}
.ab-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 13px 18px;
border-bottom: 1px solid var(--color-border);
}
.ab-modal-title {
margin: 0;
font-size: 15px;
font-weight: 650;
color: var(--color-text);
}
.ab-modal-body {
flex: 1;
padding: 16px 18px;
overflow-y: auto;
font-size: 14px;
color: var(--color-text);
}
.ab-modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 18px;
border-top: 1px solid var(--color-border);
}
</style>

View File

@@ -1,5 +1,4 @@
<script lang="ts" setup>
import { NButton } from 'naive-ui';
import { Caution, Close } from '@icon-park/vue-next';
import type { OffsetSuggestionDetail, TMDBSummary } from '#/bangumi';
@@ -216,20 +215,19 @@ function handleCancel() {
<!-- Footer -->
<footer class="dialog-footer">
<NButton
size="small"
type="primary"
secondary
<ab-button
size="sm"
variant="secondary"
@click="handleCancel"
>
{{ t('offset.cancel') }}
</NButton>
<NButton type="primary" size="small" @click="handleKeep">
</ab-button>
<ab-button variant="primary" size="sm" @click="handleKeep">
{{ t('offset.keep') }}
</NButton>
<NButton size="small" type="primary" @click="handleApply">
</ab-button>
<ab-button size="sm" variant="primary" @click="handleApply">
{{ t('offset.apply') }}
</NButton>
</ab-button>
</footer>
</div>
</div>

View File

@@ -0,0 +1,79 @@
<script lang="ts" setup>
// 细进度条(下载页):轨道用 surface-2,进行中为主色,错误为 danger。
const props = withDefaults(
defineProps<{
/** 0–100 */
value: number;
label?: string;
state?: 'active' | 'error';
}>(),
{
value: 0,
label: '',
state: 'active',
}
);
const clamped = computed(() => Math.min(100, Math.max(0, props.value)));
</script>
<template>
<div class="ab-progress">
<div
class="ab-progress-track"
role="progressbar"
:aria-valuenow="Math.round(clamped)"
aria-valuemin="0"
aria-valuemax="100"
:aria-label="label || undefined"
>
<div
class="ab-progress-fill"
:class="`ab-progress-fill--${state}`"
:style="{ transform: `scaleX(${clamped / 100})` }"
></div>
</div>
<span v-if="label" class="ab-progress-label">{{ label }}</span>
</div>
</template>
<style lang="scss" scoped>
.ab-progress {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
}
.ab-progress-track {
flex: 1;
height: 5px;
border-radius: var(--radius-full);
background: var(--color-surface-2);
overflow: hidden;
}
.ab-progress-fill {
width: 100%;
height: 100%;
border-radius: var(--radius-full);
transform-origin: left center;
transition: transform var(--transition-normal);
&--active {
background: var(--color-primary);
}
&--error {
background: var(--color-danger);
}
}
.ab-progress-label {
flex-shrink: 0;
font-family: var(--font-mono);
font-size: 11.5px;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
}
</style>

View File

@@ -0,0 +1,30 @@
import type { Meta, StoryObj } from '@storybook/vue3';
import { ref } from 'vue';
import AbSegmented from './ab-segmented.vue';
const meta: Meta<typeof AbSegmented> = {
title: 'basic/ab-segmented',
component: AbSegmented,
tags: ['autodocs'],
};
export default meta;
type Story = StoryObj<typeof AbSegmented>;
export const Template: Story = {
render: () => ({
components: { AbSegmented },
setup() {
const view = ref('all');
const options = [
{ label: 'All', value: 'all' },
{ label: 'Airing', value: 'airing' },
{ label: 'Archived', value: 'archived' },
];
return { view, options };
},
template:
'<ab-segmented v-model:value="view" :options="options" aria-label="View" />',
}),
};

View File

@@ -0,0 +1,117 @@
<script lang="ts" setup>
import { ref } from 'vue';
export interface AbSegmentedOption {
label: string;
value: string;
}
// 视图/筛选切换:Soft Ink 下划线标签页(无浮动滑块),方向键可导航。
const props = withDefaults(
defineProps<{
options: AbSegmentedOption[];
size?: 'sm' | 'md';
ariaLabel?: string;
}>(),
{
size: 'md',
ariaLabel: undefined,
}
);
const value = defineModel<string>('value', { required: true });
const tabRefs = ref<HTMLButtonElement[]>([]);
function select(option: AbSegmentedOption) {
value.value = option.value;
}
function onKeydown(event: KeyboardEvent, index: number) {
let next: number | null = null;
if (event.key === 'ArrowRight') next = (index + 1) % props.options.length;
if (event.key === 'ArrowLeft')
next = (index - 1 + props.options.length) % props.options.length;
if (next === null) return;
event.preventDefault();
value.value = props.options[next].value;
tabRefs.value[next]?.focus();
}
</script>
<template>
<div
class="ab-segmented"
:class="`ab-segmented--${size}`"
role="tablist"
:aria-label="ariaLabel"
>
<button
v-for="(option, index) in options"
:key="option.value"
:ref="(el) => (tabRefs[index] = el as HTMLButtonElement)"
type="button"
role="tab"
class="ab-segmented-tab"
:class="{ 'ab-segmented-tab--on': option.value === value }"
:aria-selected="option.value === value"
:tabindex="option.value === value ? 0 : -1"
@click="select(option)"
@keydown="onKeydown($event, index)"
>
{{ option.label }}
</button>
</div>
</template>
<style lang="scss" scoped>
.ab-segmented {
display: inline-flex;
gap: 16px;
border-bottom: 1px solid var(--color-border);
}
.ab-segmented-tab {
padding: 6px 2px;
margin-bottom: -1px;
border: none;
border-bottom: 2px solid transparent;
background: transparent;
color: var(--color-text-secondary);
font-family: inherit;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: color var(--transition-fast),
border-color var(--transition-fast);
@include forTouch {
padding: 10px 2px;
}
&:hover {
color: var(--color-text);
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
border-radius: 2px;
}
&--on {
color: var(--color-text);
font-weight: 600;
border-bottom-color: var(--color-primary);
}
}
.ab-segmented--sm .ab-segmented-tab {
font-size: 12px;
padding: 4px 2px;
@include forTouch {
padding: 8px 2px;
}
}
</style>

View File

@@ -0,0 +1,29 @@
import type { Meta, StoryObj } from '@storybook/vue3';
import { ref } from 'vue';
import AbSelect from './ab-select.vue';
const meta: Meta<typeof AbSelect> = {
title: 'basic/ab-select',
component: AbSelect,
tags: ['autodocs'],
};
export default meta;
type Story = StoryObj<typeof AbSelect>;
export const Template: Story = {
render: () => ({
components: { AbSelect },
setup() {
const value = ref('mikan');
return { value };
},
template: `
<ab-select
v-model="value"
:items="['mikan', 'dmhy', 'nyaa']"
style="width:200px"
/>`,
}),
};

View File

@@ -0,0 +1,72 @@
<script lang="ts" setup>
import { computed, inject } from 'vue';
import { NSelect } from 'naive-ui';
import { isObject } from 'radash';
import type { SelectOption } from 'naive-ui';
import { abFieldInjectionKey } from './ab-field.vue';
import type { SelectItem } from '#/components';
// NSelect 的统一封装:选项归一化 + ab-field aria 接线在这里定一次。
const props = withDefaults(
defineProps<{
/** 允许 string 或 {label?, value} 混合(旧 ab-setting 的格式) */
items?: Array<SelectItem | string>;
/** 已是 naive 格式时直接传 options */
options?: SelectOption[];
placeholder?: string;
disabled?: boolean;
error?: boolean;
ariaLabel?: string;
}>(),
{
items: undefined,
options: undefined,
placeholder: '',
disabled: false,
error: false,
ariaLabel: undefined,
}
);
const model = defineModel<string | number | null>({ default: null });
const field = inject(abFieldInjectionKey, null);
const normalizedOptions = computed<SelectOption[]>(() => {
if (props.options) return props.options;
return (props.items ?? []).map((item) =>
isObject(item)
? { label: item.label ?? String(item.value), value: item.value }
: { label: item, value: item }
);
});
const invalid = computed(() => props.error || field?.invalid.value === true);
</script>
<template>
<NSelect
v-model:value="model"
class="ab-select"
:class="{ 'ab-select--error': invalid }"
:options="normalizedOptions"
:placeholder="placeholder"
:disabled="disabled"
:aria-label="ariaLabel"
:aria-labelledby="field?.labelId"
:aria-describedby="field?.describedBy.value"
></NSelect>
</template>
<style lang="scss" scoped>
.ab-select {
min-width: 140px;
max-width: 100%;
&--error {
:deep(.n-base-selection) {
--n-border: 1px solid var(--color-danger) !important;
}
}
}
</style>

View File

@@ -0,0 +1,118 @@
<script lang="ts" setup>
// 骨架屏(加载态用骨架,不在内容中放 spinner)。
// preset 对应真实布局:lines(文本)/ row(列表行)/ card(卡片)。
withDefaults(
defineProps<{
preset?: 'lines' | 'row' | 'card';
count?: number;
}>(),
{
preset: 'lines',
count: 3,
}
);
// 行宽做点参差,避免整齐得像占位框
const widths = ['62%', '88%', '40%', '75%', '55%'];
</script>
<template>
<div class="ab-skeleton" aria-hidden="true">
<template v-if="preset === 'lines'">
<span
v-for="i in count"
:key="i"
class="ab-skeleton-line"
:style="{ width: widths[(i - 1) % widths.length] }"
></span>
</template>
<template v-else-if="preset === 'row'">
<span v-for="i in count" :key="i" class="ab-skeleton-row">
<span class="ab-skeleton-line" style="width: 46%"></span>
<span
class="ab-skeleton-line ab-skeleton-line--dim"
style="width: 68%"
></span>
</span>
</template>
<template v-else>
<span v-for="i in count" :key="i" class="ab-skeleton-card"></span>
</template>
</div>
</template>
<style lang="scss" scoped>
.ab-skeleton {
display: flex;
flex-direction: column;
gap: 9px;
width: 100%;
}
@mixin shimmer {
background: linear-gradient(
90deg,
var(--color-surface-2) 25%,
var(--color-surface-hover) 45%,
var(--color-surface-2) 65%
);
background-size: 240% 100%;
animation: ab-skeleton-shimmer 1.4s ease-in-out infinite;
@media (prefers-reduced-motion: reduce) {
animation: none;
}
}
.ab-skeleton-line {
height: 13px;
border-radius: var(--radius-sm);
@include shimmer;
&--dim {
height: 11px;
opacity: 0.7;
}
}
.ab-skeleton-row {
display: flex;
flex-direction: column;
gap: 7px;
padding: 12px 14px;
border-radius: var(--radius-md);
background: var(--color-surface-2);
.ab-skeleton-line {
background: linear-gradient(
90deg,
var(--color-surface-hover) 25%,
var(--color-surface) 45%,
var(--color-surface-hover) 65%
);
background-size: 240% 100%;
animation: ab-skeleton-shimmer 1.4s ease-in-out infinite;
@media (prefers-reduced-motion: reduce) {
animation: none;
}
}
}
.ab-skeleton-card {
height: 120px;
border-radius: var(--radius-md);
@include shimmer;
}
@keyframes ab-skeleton-shimmer {
from {
background-position: 120% 0;
}
to {
background-position: -120% 0;
}
}
</style>

View File

@@ -24,10 +24,10 @@ const props = withDefaults(
}
);
const value = defineModel<string>('value', { required: true });
const emit = defineEmits<{ click: [value: string] }>();
const value = defineModel<string>('value', { required: true });
const selected = computed(
() =>
props.options.find((option) => option.value === value.value) ??
@@ -63,7 +63,7 @@ function onMainClick() {
{{ selected?.label }}
</button>
<ab-menu :items="menuItems" align="right">
<AbMenu :items="menuItems" align="right">
<template #trigger>
<button
type="button"
@@ -74,7 +74,7 @@ function onMainClick() {
<Down size="12" />
</button>
</template>
</ab-menu>
</AbMenu>
</div>
</template>

View File

@@ -6,12 +6,19 @@ const meta: Meta<typeof AbStatus> = {
title: 'basic/ab-status',
component: AbStatus,
tags: ['autodocs'],
argTypes: {
state: {
control: 'select',
options: ['running', 'stopped', 'paused', 'degraded'],
},
},
};
export default meta;
type Story = StoryObj<typeof AbStatus>;
export const Template: Story = {
args: { state: 'running', label: 'Running', detail: 'next poll 3 min' },
render: (args) => ({
components: { AbStatus },
setup() {
@@ -20,3 +27,17 @@ export const Template: Story = {
template: '<ab-status v-bind="args" />',
}),
};
export const AllStates: Story = {
render: () => ({
components: { AbStatus },
template: `
<div style="display:flex;flex-direction:column;gap:10px">
<ab-status state="running" label="Running" detail="next poll 3 min" />
<ab-status state="stopped" label="Stopped" detail="downloader unreachable" />
<ab-status state="paused" label="Paused" detail="by user" />
<ab-status state="degraded" label="Degraded" detail="2 feeds failing" />
<ab-status state="running" size="sm" label="Compact" />
</div>`,
}),
};

View File

@@ -1,70 +1,109 @@
<script lang="ts" setup>
withDefaults(
// Soft Ink 状态标记:方形色块 + 墨色文字。页面上唯一的语义色载体,
// 回答“是否在正常工作”。四种状态取代旧的 running 布尔值。
const props = withDefaults(
defineProps<{
running: boolean;
size?: string;
state: 'running' | 'stopped' | 'paused' | 'degraded';
/** 状态文字(省略则只渲染色块,需自带 aria 上下文) */
label?: string;
/** 次要说明,如 “next poll 3 min” */
detail?: string;
size?: 'sm' | 'md';
}>(),
{
running: false,
size: '1em',
label: '',
detail: '',
size: 'md',
}
);
const ariaText = computed(() => props.label || props.state);
</script>
<template>
<div
class="status-indicator"
:style="{ width: size, height: size }"
<span
class="ab-status"
:class="[`ab-status--${state}`, `ab-status--${size}`]"
role="status"
:aria-label="running ? 'System running' : 'System stopped'"
:aria-label="detail ? `${ariaText} — ${detail}` : ariaText"
>
<div class="status-ring">
<div
class="status-dot"
:class="[running ? 'status-dot--running' : 'status-dot--stopped']"
></div>
</div>
</div>
<span class="ab-status-mark" aria-hidden="true"></span>
<span v-if="label" class="ab-status-label">{{ label }}</span>
<span v-if="detail" class="ab-status-detail">{{ detail }}</span>
</span>
</template>
<style lang="scss" scoped>
.status-indicator {
display: flex;
.ab-status {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
font-size: 13px;
font-weight: 500;
color: var(--color-text);
&--sm {
font-size: 12px;
gap: 6px;
}
}
.status-ring {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
border: 2px solid var(--color-border);
transition: border-color var(--transition-normal);
}
.status-dot {
.ab-status-mark {
width: 10px;
height: 10px;
border-radius: 50%;
border-radius: 2.5px;
flex-shrink: 0;
transition: background-color var(--transition-fast);
&--running {
background: var(--color-success);
box-shadow: 0 0 6px color-mix(in srgb, var(--color-success) 40%, transparent);
animation: pulse 2s ease-in-out infinite;
.ab-status--sm & {
width: 8px;
height: 8px;
border-radius: 2px;
}
&--stopped {
.ab-status--running & {
background: var(--color-success);
animation: ab-status-pulse 2s ease-in-out infinite;
}
.ab-status--stopped & {
background: var(--color-danger);
box-shadow: 0 0 6px color-mix(in srgb, var(--color-danger) 40%, transparent);
}
.ab-status--paused & {
background: var(--color-text-muted);
}
.ab-status--degraded & {
background: var(--color-warning);
}
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
.ab-status-label {
color: var(--color-text);
}
.ab-status-detail {
color: var(--color-text-secondary);
font-weight: 400;
font-family: var(--font-mono);
font-size: 0.92em;
font-variant-numeric: tabular-nums;
}
@keyframes ab-status-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.55;
}
}
@media (prefers-reduced-motion: reduce) {
.ab-status-mark {
animation: none !important;
}
}
</style>

View File

@@ -0,0 +1,30 @@
import type { Meta, StoryObj } from '@storybook/vue3';
import { ref } from 'vue';
import AbSwitch from './ab-switch.vue';
const meta: Meta<typeof AbSwitch> = {
title: 'basic/ab-switch',
component: AbSwitch,
tags: ['autodocs'],
};
export default meta;
type Story = StoryObj<typeof AbSwitch>;
export const Template: Story = {
render: () => ({
components: { AbSwitch },
setup() {
const on = ref(true);
const off = ref(false);
return { on, off };
},
template: `
<div style="display:flex;gap:14px;align-items:center">
<ab-switch v-model="on" aria-label="Auto rename" />
<ab-switch v-model="off" aria-label="Proxy" />
<ab-switch :model-value="true" disabled aria-label="Disabled" />
</div>`,
}),
};

View File

@@ -0,0 +1,36 @@
<script lang="ts" setup>
import { inject } from 'vue';
import { NSwitch } from 'naive-ui';
import { abFieldInjectionKey } from './ab-field.vue';
// NSwitch 的统一封装:尺寸、loading 与 aria 接线在这里定一次。
withDefaults(
defineProps<{
size?: 'small' | 'medium' | 'large';
disabled?: boolean;
loading?: boolean;
ariaLabel?: string;
}>(),
{
size: 'medium',
disabled: false,
loading: false,
ariaLabel: undefined,
}
);
const model = defineModel<boolean>({ default: false });
const field = inject(abFieldInjectionKey, null);
</script>
<template>
<NSwitch
v-model:value="model"
:size="size"
:disabled="disabled"
:loading="loading"
:aria-label="ariaLabel"
:aria-labelledby="field?.labelId"
></NSwitch>
</template>

View File

@@ -6,12 +6,19 @@ const meta: Meta<typeof AbTag> = {
title: 'basic/ab-tag',
component: AbTag,
tags: ['autodocs'],
argTypes: {
type: {
control: 'select',
options: ['success', 'warning', 'danger', 'info', 'neutral'],
},
},
};
export default meta;
type Story = StoryObj<typeof AbTag>;
export const Template: Story = {
args: { type: 'success', title: 'Airing' },
render: (args) => ({
components: { AbTag },
setup() {
@@ -20,3 +27,19 @@ export const Template: Story = {
template: '<ab-tag v-bind="args" />',
}),
};
export const AllTypes: Story = {
render: () => ({
components: { AbTag },
template: `
<div style="display:flex;gap:8px;flex-wrap:wrap">
<ab-tag type="success" title="Airing" />
<ab-tag type="neutral" title="Archived" />
<ab-tag type="info" title="S02E08" />
<ab-tag type="warning" title="Needs review" />
<ab-tag type="danger" title="Parse failed" />
<ab-tag type="info" title="葬送的芙莉莲 第二季" closable />
<ab-tag type="neutral" title="桜都字幕组 · 简日双语 · 1080p" />
</div>`,
}),
};

View File

@@ -1,67 +1,120 @@
<script lang="ts" setup>
// Soft Ink 标签:细线框 + 语义色方块标记,文字始终为墨色 —
// 颜色不承载文字(拒绝粉彩胶囊)。不截断 CJK 标题。
withDefaults(
defineProps<{
type: 'primary' | 'warn' | 'inactive' | 'active' | 'notify';
title: string;
type?: 'success' | 'warning' | 'danger' | 'info' | 'neutral';
title?: string;
closable?: boolean;
}>(),
{
type: 'primary',
title: 'title',
type: 'neutral',
title: '',
closable: false,
}
);
defineEmits<{ close: [] }>();
</script>
<template>
<span class="tag" :class="`tag--${type}`">
{{ title }}
<span class="ab-tag" :class="`ab-tag--${type}`">
<span class="ab-tag-text"><slot>{{ title }}</slot></span>
<button
v-if="closable"
type="button"
class="ab-tag-close"
:aria-label="$t('common.cancel')"
@click.stop="$emit('close')"
>
<svg viewBox="0 0 24 24" fill="none" width="10" height="10">
<path
d="M6 6l12 12M18 6 6 18"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
/>
</svg>
</button>
</span>
</template>
<style lang="scss" scoped>
.tag {
.ab-tag {
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 100%;
padding: 2px 8px;
border-radius: var(--radius-full);
font-size: 10px;
border: 1px solid var(--color-border);
border-radius: 4px;
background: transparent;
color: var(--color-text);
font-size: 12px;
font-weight: 500;
max-width: 80px;
transition: border-color var(--transition-fast),
color var(--transition-fast);
// 语义色方块标记
&::before {
content: '';
width: 7px;
height: 7px;
border-radius: 2px;
flex-shrink: 0;
}
&--success::before {
background: var(--color-success);
}
&--warning::before {
background: var(--color-warning);
}
&--danger::before {
background: var(--color-danger);
}
&--info::before {
background: var(--color-primary);
}
&--neutral {
color: var(--color-text-secondary);
&::before {
background: var(--color-text-muted);
}
}
}
.ab-tag-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
border: 1px solid;
transition: background-color var(--transition-fast),
border-color var(--transition-fast),
color var(--transition-fast);
}
&--primary {
background: var(--color-primary-light);
border-color: var(--color-primary);
color: var(--color-primary);
.ab-tag-close {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
padding: 2px;
border: none;
border-radius: 2px;
background: transparent;
color: var(--color-text-muted);
cursor: pointer;
transition: color var(--transition-fast);
&:hover {
color: var(--color-text);
}
&--warn {
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
border-color: var(--color-danger);
color: var(--color-danger);
}
&--inactive {
background: var(--color-surface-hover);
border-color: var(--color-border);
color: var(--color-text-muted);
}
&--active {
background: color-mix(in srgb, var(--color-success) 10%, transparent);
border-color: var(--color-success);
color: var(--color-success);
}
&--notify {
background: color-mix(in srgb, var(--color-warning) 10%, transparent);
border-color: var(--color-warning);
color: var(--color-warning);
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 1px;
}
}
</style>

View File

@@ -0,0 +1,64 @@
<script lang="ts" setup>
// 列表上方的操作行:搜索 / 筛选 / 动作三个插槽,一处定义响应式换行。
withDefaults(
defineProps<{
/** 吸顶(长列表页) */
sticky?: boolean;
}>(),
{
sticky: false,
}
);
</script>
<template>
<div class="ab-toolbar" :class="{ 'ab-toolbar--sticky': sticky }">
<div v-if="$slots.search" class="ab-toolbar-search">
<slot name="search"></slot>
</div>
<div v-if="$slots.filters" class="ab-toolbar-filters">
<slot name="filters"></slot>
</div>
<div v-if="$slots.actions" class="ab-toolbar-actions">
<slot name="actions"></slot>
</div>
</div>
</template>
<style lang="scss" scoped>
.ab-toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
width: 100%;
&--sticky {
position: sticky;
top: 0;
z-index: var(--z-sticky);
background: color-mix(in srgb, var(--color-bg) 92%, transparent);
backdrop-filter: blur(6px);
padding: 8px 0;
}
}
.ab-toolbar-search {
flex: 1;
min-width: 180px;
}
.ab-toolbar-filters {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
}
.ab-toolbar-actions {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
}
</style>

View File

@@ -0,0 +1,27 @@
import type { Meta, StoryObj } from '@storybook/vue3';
import AbButton from './ab-button.vue';
import AbTooltip from './ab-tooltip.vue';
const meta: Meta<typeof AbTooltip> = {
title: 'basic/ab-tooltip',
component: AbTooltip,
tags: ['autodocs'],
};
export default meta;
type Story = StoryObj<typeof AbTooltip>;
export const Template: Story = {
args: { content: 'Refresh all RSS feeds' },
render: (args) => ({
components: { AbTooltip, AbButton },
setup() {
return { args };
},
template: `
<ab-tooltip v-bind="args">
<ab-button variant="secondary">Hover me</ab-button>
</ab-tooltip>`,
}),
};

View File

@@ -0,0 +1,34 @@
<script lang="ts" setup>
import { NTooltip } from 'naive-ui';
// NTooltip 的统一封装:延迟、触屏回退(点击触发)与样式只在这里定一次。
withDefaults(
defineProps<{
content?: string;
placement?:
| 'top'
| 'bottom'
| 'left'
| 'right'
| 'top-start'
| 'top-end'
| 'bottom-start'
| 'bottom-end';
delay?: number;
}>(),
{
content: '',
placement: 'top',
delay: 400,
}
);
</script>
<template>
<NTooltip :placement="placement" :delay="delay" trigger="hover">
<template #trigger>
<slot />
</template>
<slot name="content">{{ content }}</slot>
</NTooltip>
</template>

View File

@@ -0,0 +1,43 @@
import { reactive } from 'vue';
export interface ConfirmOptions {
title: string;
body?: string;
confirmText?: string;
cancelText?: string;
/** 破坏性操作:确认按钮使用 danger 变体 */
danger?: boolean;
}
interface ConfirmRequest extends ConfirmOptions {
resolve: (value: boolean) => void;
}
// 模块级单例状态:ab-confirm-host(挂载于 App.vue)消费并渲染
const state = reactive<{ current: ConfirmRequest | null }>({ current: null });
/**
* Promise 式确认对话框,替代 NPopconfirm / 手搭确认弹窗:
* `if (await confirm({ title, danger: true })) { … }`
*/
export function useConfirm() {
function confirm(options: ConfirmOptions): Promise<boolean> {
return new Promise<boolean>((resolve) => {
// 已有未决确认时,先取消旧的,避免悬挂的 Promise
state.current?.resolve(false);
state.current = { ...options, resolve };
});
}
return { confirm };
}
/** 仅供 ab-confirm-host 内部使用 */
export function useConfirmState() {
function settle(value: boolean) {
state.current?.resolve(value);
state.current = null;
}
return { state, settle };
}

View File

@@ -1,7 +1,35 @@
import { createDiscreteApi } from 'naive-ui';
import type { GlobalThemeOverrides } from 'naive-ui';
import { createSharedComposable } from '@vueuse/core';
// Soft Ink toast:反色墨条 —— 所有类型同一深底,语义色只出现在图标上。
// (组件级 token 可以用 CSS 变量;只有 common 必须是字面量)
const toastTheme: GlobalThemeOverrides = {
Message: {
color: 'var(--color-text)',
colorInfo: 'var(--color-text)',
colorSuccess: 'var(--color-text)',
colorError: 'var(--color-text)',
colorWarning: 'var(--color-text)',
colorLoading: 'var(--color-text)',
textColor: 'var(--color-bg)',
textColorInfo: 'var(--color-bg)',
textColorSuccess: 'var(--color-bg)',
textColorError: 'var(--color-bg)',
textColorWarning: 'var(--color-bg)',
textColorLoading: 'var(--color-bg)',
iconColorInfo: 'var(--color-primary-light)',
iconColorSuccess: 'var(--color-success)',
iconColorError: 'var(--color-danger)',
iconColorWarning: 'var(--color-warning)',
borderRadius: 'var(--radius-md)',
boxShadow: 'var(--shadow-md)',
},
};
export const useMessage = createSharedComposable(() => {
const { message } = createDiscreteApi(['message']);
const { message } = createDiscreteApi(['message'], {
configProviderProps: { themeOverrides: toastTheme },
});
return message;
});

View File

@@ -484,7 +484,11 @@
"select": "Select",
"selectAll": "Select All",
"items": "items",
"moreActions": "More actions"
"moreActions": "More actions",
"copy": "Copy",
"clear": "Clear",
"toggle_password": "Show or hide password",
"empty": "Nothing here yet"
},
"theme": {
"light": "Light",

View File

@@ -484,7 +484,11 @@
"select": "选择",
"selectAll": "全选",
"items": "项",
"moreActions": "更多操作"
"moreActions": "更多操作",
"copy": "复制",
"clear": "清空",
"toggle_password": "显示或隐藏密码",
"empty": "暂无内容"
},
"theme": {
"light": "浅色",

View File

@@ -11,6 +11,11 @@ export interface AbSettingProps {
css?: string;
prop?: any;
bottomLine?: boolean;
/** 字段说明文字(显示在标签下方) */
description?: string;
/** 校验错误文案(显示为错误态) */
error?: string;
required?: boolean;
}
export type SettingItem<T> = AbSettingProps & {

View File

@@ -105,6 +105,8 @@ declare global {
const useBreakpoints: typeof import('@vueuse/core')['useBreakpoints']
const useClipboard: typeof import('@vueuse/core')['useClipboard']
const useConfigStore: typeof import('../../src/store/config')['useConfigStore']
const useConfirm: typeof import('../../src/hooks/useConfirm')['useConfirm']
const useConfirmState: typeof import('../../src/hooks/useConfirm')['useConfirmState']
const useCssModule: typeof import('vue')['useCssModule']
const useCssVars: typeof import('vue')['useCssVars']
const useDarkMode: typeof import('../../src/hooks/useDarkMode')['useDarkMode']

View File

@@ -12,21 +12,33 @@ declare module '@vue/runtime-core' {
AbAdaptiveModal: typeof import('./../../src/components/basic/ab-adaptive-modal.vue')['default']
AbAdd: typeof import('./../../src/components/basic/ab-add.vue')['default']
AbAddRss: typeof import('./../../src/components/ab-add-rss.vue')['default']
AbAlert: typeof import('./../../src/components/basic/ab-alert.vue')['default']
AbBadge: typeof import('./../../src/components/basic/ab-badge.vue')['default']
AbBangumiCard: typeof import('./../../src/components/ab-bangumi-card.vue')['default']
AbBottomSheet: typeof import('./../../src/components/basic/ab-bottom-sheet.vue')['default']
AbButton: typeof import('./../../src/components/basic/ab-button.vue')['default']
AbButtonMulti: typeof import('./../../src/components/basic/ab-button-multi.vue')['default']
AbChangeAccount: typeof import('./../../src/components/ab-change-account.vue')['default']
AbConfirmHost: typeof import('./../../src/components/basic/ab-confirm-host.vue')['default']
AbContainer: typeof import('./../../src/components/ab-container.vue')['default']
AbDataList: typeof import('./../../src/components/basic/ab-data-list.vue')['default']
AbEditRule: typeof import('./../../src/components/ab-edit-rule.vue')['default']
AbEmpty: typeof import('./../../src/components/basic/ab-empty.vue')['default']
AbField: typeof import('./../../src/components/basic/ab-field.vue')['default']
AbFoldPanel: typeof import('./../../src/components/ab-fold-panel.vue')['default']
AbIconButton: typeof import('./../../src/components/basic/ab-icon-button.vue')['default']
AbImage: typeof import('./../../src/components/ab-image.vue')['default']
AbInput: typeof import('./../../src/components/basic/ab-input.vue')['default']
AbLabel: typeof import('./../../src/components/ab-label.vue')['default']
AbList: typeof import('./../../src/components/basic/ab-list.vue')['default']
AbMenu: typeof import('./../../src/components/basic/ab-menu.vue')['default']
AbMobileNav: typeof import('./../../src/components/layout/ab-mobile-nav.vue')['default']
AbModal: typeof import('./../../src/components/basic/ab-modal.vue')['default']
AbNotificationCenter: typeof import('./../../src/components/layout/ab-notification-center.vue')['default']
AbOffsetMismatchDialog: typeof import('./../../src/components/basic/ab-offset-mismatch-dialog.vue')['default']
AbPageTitle: typeof import('./../../src/components/basic/ab-page-title.vue')['default']
AbPopup: typeof import('./../../src/components/ab-popup.vue')['default']
AbProgress: typeof import('./../../src/components/basic/ab-progress.vue')['default']
AbPullRefresh: typeof import('./../../src/components/basic/ab-pull-refresh.vue')['default']
AbRule: typeof import('./../../src/components/ab-rule.vue')['default']
AbSearch: typeof import('./../../src/components/basic/ab-search.vue')['default']
@@ -35,12 +47,19 @@ declare module '@vue/runtime-core' {
AbSearchConfirm: typeof import('./../../src/components/search/ab-search-confirm.vue')['default']
AbSearchFilters: typeof import('./../../src/components/search/ab-search-filters.vue')['default']
AbSearchModal: typeof import('./../../src/components/search/ab-search-modal.vue')['default']
AbSegmented: typeof import('./../../src/components/basic/ab-segmented.vue')['default']
AbSelect: typeof import('./../../src/components/basic/ab-select.vue')['default']
AbSetting: typeof import('./../../src/components/ab-setting.vue')['default']
AbSidebar: typeof import('./../../src/components/layout/ab-sidebar.vue')['default']
AbSkeleton: typeof import('./../../src/components/basic/ab-skeleton.vue')['default']
AbSplitButton: typeof import('./../../src/components/basic/ab-split-button.vue')['default']
AbStatus: typeof import('./../../src/components/basic/ab-status.vue')['default']
AbStatusBar: typeof import('./../../src/components/ab-status-bar.vue')['default']
AbSwipeContainer: typeof import('./../../src/components/basic/ab-swipe-container.vue')['default']
AbSwitch: typeof import('./../../src/components/basic/ab-switch.vue')['default']
AbTag: typeof import('./../../src/components/basic/ab-tag.vue')['default']
AbToolbar: typeof import('./../../src/components/basic/ab-toolbar.vue')['default']
AbTooltip: typeof import('./../../src/components/basic/ab-tooltip.vue')['default']
AbTopbar: typeof import('./../../src/components/layout/ab-topbar.vue')['default']
AdvancedSection: typeof import('./../../src/components/advanced-section.vue')['default']
BangumiFilterField: typeof import('./../../src/components/bangumi-filter-field.vue')['default']