@aggc/ui 0.4.1 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -0
- package/package.json +4 -2
- package/src/components/PageSurface.styles.ts +3 -0
- package/src/components/PageSurface.vue +9 -0
- package/src/components/ResultPanel.styles.ts +108 -0
- package/src/components/ResultPanel.test.ts +22 -0
- package/src/components/ResultPanel.vue +70 -0
- package/src/components/SectionCard.styles.ts +65 -0
- package/src/components/SectionCard.test.ts +22 -0
- package/src/components/SectionCard.vue +51 -0
- package/src/components/StatusBadge.styles.ts +49 -0
- package/src/components/StatusBadge.test.ts +18 -0
- package/src/components/StatusBadge.vue +18 -0
- package/src/components/UiButton.styles.ts +29 -0
- package/src/components/UiButton.test.ts +21 -0
- package/src/components/UiButton.vue +46 -0
- package/src/components/UiCheckbox.styles.ts +118 -0
- package/src/components/UiCheckbox.test.ts +18 -0
- package/src/components/UiCheckbox.vue +72 -0
- package/src/components/UiField.styles.ts +35 -0
- package/src/components/UiField.test.ts +22 -0
- package/src/components/UiField.vue +36 -0
- package/src/components/UiLoadingState.styles.ts +36 -0
- package/src/components/UiLoadingState.vue +34 -0
- package/src/components/UiSegmentedControl.styles.ts +49 -0
- package/src/components/UiSegmentedControl.vue +30 -0
- package/src/components/UiSelect.styles.ts +214 -0
- package/src/components/UiSelect.test.ts +49 -0
- package/src/components/UiSelect.vue +256 -0
- package/src/components/UiSkeleton.styles.ts +93 -0
- package/src/components/UiSkeleton.vue +48 -0
- package/src/components/index.ts +11 -0
- package/src/components.ts +1 -0
- package/src/css/base.css +62 -0
- package/src/css/fonts.css +6 -0
- package/src/css/index.css +2 -0
- package/src/css/storybook.css +15 -0
- package/src/env.d.ts +1 -0
- package/src/index.ts +3 -0
- package/src/stories/feedback/ResultPanel.stories.ts +76 -0
- package/src/stories/feedback/StatusBadge.stories.ts +50 -0
- package/src/stories/feedback/UiLoadingState.stories.ts +52 -0
- package/src/stories/feedback/UiSkeleton.stories.ts +85 -0
- package/src/stories/forms/UiCheckbox.stories.ts +104 -0
- package/src/stories/forms/UiField.stories.ts +87 -0
- package/src/stories/forms/UiSelect.stories.ts +134 -0
- package/src/stories/layout/PageSurface.stories.ts +53 -0
- package/src/stories/layout/SectionCard.stories.ts +85 -0
- package/src/stories/primitives/UiButton.stories.ts +145 -0
- package/src/stories/primitives/UiSegmentedControl.stories.ts +67 -0
- package/src/stories/support/StoryThemeFrame.vue +101 -0
- package/src/stories/support/sources.ts +374 -0
- package/src/stories/support/storyStyles.ts +150 -0
- package/src/styles/README.md +23 -0
- package/src/styles/index.ts +20 -0
- package/src/styles/layouts/cluster.ts +27 -0
- package/src/styles/layouts/page.ts +22 -0
- package/src/styles/layouts/split.ts +26 -0
- package/src/styles/layouts/stack.ts +21 -0
- package/src/styles/patterns/actionToolbar.ts +8 -0
- package/src/styles/patterns/emptyState.ts +23 -0
- package/src/styles/patterns/infoPanel.ts +22 -0
- package/src/styles/patterns/metricGrid.ts +19 -0
- package/src/styles/patterns/pageHeader.ts +19 -0
- package/src/styles/patterns/resultRegion.ts +7 -0
- package/src/styles/patterns/selectableListDetail.ts +21 -0
- package/src/styles/primitives/feedback.ts +23 -0
- package/src/styles/primitives/fields.ts +76 -0
- package/src/styles/primitives/surfaces.ts +52 -0
- package/src/styles/primitives/typography.ts +42 -0
- package/src/styles/recipes/badge.recipe.ts +54 -0
- package/src/styles/recipes/button.recipe.ts +115 -0
- package/src/styles/recipes/card.recipe.ts +64 -0
- package/src/styles/recipes/dropdown.recipe.ts +40 -0
- package/src/styles/recipes/input.recipe.ts +59 -0
- package/src/styles.ts +1 -0
- package/src/test/setup.ts +1 -0
- package/src/tokens/colors.ts +16 -0
- package/src/tokens/core-colors.ts +53 -0
- package/src/tokens/desktop-colors.ts +37 -0
- package/src/tokens/index.ts +8 -0
- package/src/tokens/motion.ts +6 -0
- package/src/tokens/radius.ts +3 -0
- package/src/tokens/spacing.ts +4 -0
- package/src/tokens/typography.ts +6 -0
- package/src/tokens-core.ts +5 -0
- package/src/tokens-desktop.ts +1 -0
- package/src/tokens.ts +1 -0
package/README.md
CHANGED
|
@@ -51,6 +51,25 @@ You can also consume the public subpaths:
|
|
|
51
51
|
- `@aggc/ui/tokens/desktop-only` — desktop-only tokens: `result.*`, `nav.*`, `sync.*`
|
|
52
52
|
- `@aggc/ui/ui.css` — direct CSS asset (for bundlers that handle CSS imports natively)
|
|
53
53
|
|
|
54
|
+
## PandaCSS consumer setup
|
|
55
|
+
|
|
56
|
+
If you use PandaCSS in your app, add the package sources to your `panda.config.ts` scanner.
|
|
57
|
+
This is required because PandaCSS does static analysis — it needs the original source files where
|
|
58
|
+
`cva`, `css`, etc. appear by name. The compiled `dist/` renames these functions and PandaCSS
|
|
59
|
+
cannot recognize them.
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
// panda.config.ts
|
|
63
|
+
export default defineConfig({
|
|
64
|
+
include: [
|
|
65
|
+
'./src/**/*.{ts,vue}',
|
|
66
|
+
'./node_modules/@aggc/ui/src/**/*.{ts,vue}',
|
|
67
|
+
],
|
|
68
|
+
})
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The `src/` directory is published in the package specifically for this purpose.
|
|
72
|
+
|
|
54
73
|
Everything else in this repo is maintenance tooling for the package itself:
|
|
55
74
|
|
|
56
75
|
- `src/stories/**` powers Storybook docs
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aggc/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "Shared Vue UI primitives, patterns, and tokens for AGGC desktop and web.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -50,7 +50,8 @@
|
|
|
50
50
|
"*.css"
|
|
51
51
|
],
|
|
52
52
|
"files": [
|
|
53
|
-
"dist"
|
|
53
|
+
"dist",
|
|
54
|
+
"src"
|
|
54
55
|
],
|
|
55
56
|
"main": "./dist/index.js",
|
|
56
57
|
"module": "./dist/index.js",
|
|
@@ -102,6 +103,7 @@
|
|
|
102
103
|
"@playwright/test": "^1.52.0",
|
|
103
104
|
"@storybook/addon-a11y": "^9.1.20",
|
|
104
105
|
"@storybook/addon-docs": "^9.1.20",
|
|
106
|
+
"@storybook/addon-mcp": "^0.4.2",
|
|
105
107
|
"@storybook/vue3-vite": "^9.1.20",
|
|
106
108
|
"@types/node": "^24.0.15",
|
|
107
109
|
"@vitejs/plugin-vue": "^6.0.1",
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { cva, css, cx } from "@styled/css";
|
|
2
|
+
|
|
3
|
+
import { surfacePanelClass } from "../styles";
|
|
4
|
+
|
|
5
|
+
export const resultPanelClass = cva({
|
|
6
|
+
base: {
|
|
7
|
+
borderWidth: "1px",
|
|
8
|
+
padding: "5",
|
|
9
|
+
},
|
|
10
|
+
variants: {
|
|
11
|
+
ok: {
|
|
12
|
+
true: {
|
|
13
|
+
borderColor: "result.okBorder",
|
|
14
|
+
bg: "result.okBg",
|
|
15
|
+
},
|
|
16
|
+
false: {
|
|
17
|
+
borderColor: "result.failBorder",
|
|
18
|
+
bg: "result.failBg",
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export const resultPanelRootClass = (ok: boolean) => cx(surfacePanelClass, resultPanelClass({ ok }));
|
|
25
|
+
|
|
26
|
+
export const resultPanelHeaderClass = css({
|
|
27
|
+
display: "flex",
|
|
28
|
+
justifyContent: "space-between",
|
|
29
|
+
gap: "4",
|
|
30
|
+
alignItems: { base: "flex-start", md: "center" },
|
|
31
|
+
flexDirection: { base: "column", md: "row" },
|
|
32
|
+
mb: "3",
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
export const resultPanelSummaryClass = css({
|
|
36
|
+
display: "flex",
|
|
37
|
+
alignItems: "center",
|
|
38
|
+
gap: "3",
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
export const resultPanelIconClass = cva({
|
|
42
|
+
base: {
|
|
43
|
+
width: "10",
|
|
44
|
+
height: "10",
|
|
45
|
+
borderRadius: "full",
|
|
46
|
+
display: "flex",
|
|
47
|
+
alignItems: "center",
|
|
48
|
+
justifyContent: "center",
|
|
49
|
+
},
|
|
50
|
+
variants: {
|
|
51
|
+
ok: {
|
|
52
|
+
true: {
|
|
53
|
+
bg: "bg.accentSoft",
|
|
54
|
+
color: "text.accent",
|
|
55
|
+
},
|
|
56
|
+
false: {
|
|
57
|
+
bg: "badge.warningBg",
|
|
58
|
+
color: "badge.warningText",
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
export const resultPanelTitleClass = css({
|
|
65
|
+
fontSize: "lg",
|
|
66
|
+
fontWeight: "700",
|
|
67
|
+
color: "text.primary",
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
export const resultPanelSectionClass = cva({
|
|
71
|
+
base: {},
|
|
72
|
+
variants: {
|
|
73
|
+
spaced: {
|
|
74
|
+
true: { mb: "3" },
|
|
75
|
+
false: { mb: "0" },
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
export const resultPanelSectionTitleClass = cva({
|
|
81
|
+
base: {
|
|
82
|
+
fontSize: "sm",
|
|
83
|
+
fontWeight: "700",
|
|
84
|
+
mb: "2",
|
|
85
|
+
},
|
|
86
|
+
variants: {
|
|
87
|
+
tone: {
|
|
88
|
+
detail: { color: "result.detailText" },
|
|
89
|
+
warning: { color: "result.warningLabel" },
|
|
90
|
+
error: { color: "result.errorLabel" },
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
export const resultPanelListClass = cva({
|
|
96
|
+
base: {
|
|
97
|
+
display: "grid",
|
|
98
|
+
gap: "1.5",
|
|
99
|
+
pl: "4",
|
|
100
|
+
},
|
|
101
|
+
variants: {
|
|
102
|
+
tone: {
|
|
103
|
+
detail: { color: "result.bodyText" },
|
|
104
|
+
warning: { color: "result.warningBody" },
|
|
105
|
+
error: { color: "result.errorBody" },
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { mount } from "@vue/test-utils";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import ResultPanel from "./ResultPanel.vue";
|
|
4
|
+
|
|
5
|
+
describe("ResultPanel", () => {
|
|
6
|
+
it("renders configurable copy and polite live region by default", () => {
|
|
7
|
+
const wrapper = mount(ResultPanel, {
|
|
8
|
+
props: {
|
|
9
|
+
ok: false,
|
|
10
|
+
summary: "Validation failed",
|
|
11
|
+
failLabel: "Failed",
|
|
12
|
+
warningsLabel: "Heads up",
|
|
13
|
+
warnings: ["One warning"],
|
|
14
|
+
},
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
expect(wrapper.attributes("role")).toBe("status");
|
|
18
|
+
expect(wrapper.attributes("aria-live")).toBe("polite");
|
|
19
|
+
expect(wrapper.text()).toContain("Failed");
|
|
20
|
+
expect(wrapper.text()).toContain("Heads up");
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { CheckCircle2, TriangleAlert } from "lucide-vue-next";
|
|
3
|
+
|
|
4
|
+
import StatusBadge from "./StatusBadge.vue";
|
|
5
|
+
import {
|
|
6
|
+
resultPanelHeaderClass,
|
|
7
|
+
resultPanelIconClass,
|
|
8
|
+
resultPanelListClass,
|
|
9
|
+
resultPanelRootClass,
|
|
10
|
+
resultPanelSectionClass,
|
|
11
|
+
resultPanelSectionTitleClass,
|
|
12
|
+
resultPanelSummaryClass,
|
|
13
|
+
resultPanelTitleClass,
|
|
14
|
+
} from "./ResultPanel.styles";
|
|
15
|
+
|
|
16
|
+
const props = defineProps<{
|
|
17
|
+
ok: boolean;
|
|
18
|
+
summary: string;
|
|
19
|
+
warnings?: string[];
|
|
20
|
+
errors?: string[];
|
|
21
|
+
messages?: string[];
|
|
22
|
+
live?: "polite" | "assertive" | "off";
|
|
23
|
+
passLabel?: string;
|
|
24
|
+
failLabel?: string;
|
|
25
|
+
detailsLabel?: string;
|
|
26
|
+
warningsLabel?: string;
|
|
27
|
+
errorsLabel?: string;
|
|
28
|
+
}>();
|
|
29
|
+
</script>
|
|
30
|
+
|
|
31
|
+
<template>
|
|
32
|
+
<div
|
|
33
|
+
:class="resultPanelRootClass(ok)"
|
|
34
|
+
:aria-live="props.live ?? 'polite'"
|
|
35
|
+
:role="(props.live ?? 'polite') === 'off' ? undefined : 'status'"
|
|
36
|
+
>
|
|
37
|
+
<div :class="resultPanelHeaderClass">
|
|
38
|
+
<div :class="resultPanelSummaryClass">
|
|
39
|
+
<div :class="resultPanelIconClass({ ok })">
|
|
40
|
+
<component :is="ok ? CheckCircle2 : TriangleAlert" :size="18" />
|
|
41
|
+
</div>
|
|
42
|
+
<h3 :class="resultPanelTitleClass">{{ props.summary }}</h3>
|
|
43
|
+
</div>
|
|
44
|
+
<StatusBadge :tone="ok ? 'success' : 'warning'">
|
|
45
|
+
{{ ok ? props.passLabel ?? "Pass" : props.failLabel ?? "Fail" }}
|
|
46
|
+
</StatusBadge>
|
|
47
|
+
</div>
|
|
48
|
+
|
|
49
|
+
<div v-if="messages?.length" :class="resultPanelSectionClass({ spaced: true })">
|
|
50
|
+
<p :class="resultPanelSectionTitleClass({ tone: 'detail' })">{{ props.detailsLabel ?? "Details" }}</p>
|
|
51
|
+
<ul :class="resultPanelListClass({ tone: 'detail' })">
|
|
52
|
+
<li v-for="message in messages" :key="message">{{ message }}</li>
|
|
53
|
+
</ul>
|
|
54
|
+
</div>
|
|
55
|
+
|
|
56
|
+
<div v-if="warnings?.length" :class="resultPanelSectionClass({ spaced: !!errors?.length })">
|
|
57
|
+
<p :class="resultPanelSectionTitleClass({ tone: 'warning' })">{{ props.warningsLabel ?? "Warnings" }}</p>
|
|
58
|
+
<ul :class="resultPanelListClass({ tone: 'warning' })">
|
|
59
|
+
<li v-for="warning in warnings" :key="warning">{{ warning }}</li>
|
|
60
|
+
</ul>
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
<div v-if="errors?.length">
|
|
64
|
+
<p :class="resultPanelSectionTitleClass({ tone: 'error' })">{{ props.errorsLabel ?? "Errors" }}</p>
|
|
65
|
+
<ul :class="resultPanelListClass({ tone: 'error' })">
|
|
66
|
+
<li v-for="error in errors" :key="error">{{ error }}</li>
|
|
67
|
+
</ul>
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
</template>
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { css } from "@styled/css";
|
|
2
|
+
import {
|
|
3
|
+
pageHeaderActionsClass,
|
|
4
|
+
pageHeaderContentClass,
|
|
5
|
+
pageHeaderRootClass,
|
|
6
|
+
sectionTitleClass,
|
|
7
|
+
surfacePanelClass,
|
|
8
|
+
eyebrowClass,
|
|
9
|
+
} from "../styles";
|
|
10
|
+
|
|
11
|
+
export const sectionCardClass = css({
|
|
12
|
+
padding: "6",
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export const sectionCardScrollClass = css({
|
|
16
|
+
height: "100%",
|
|
17
|
+
minHeight: "0",
|
|
18
|
+
display: "grid",
|
|
19
|
+
gridTemplateRows: "auto minmax(0, 1fr)",
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export const sectionCardSurfaceClass = surfacePanelClass;
|
|
23
|
+
|
|
24
|
+
export const sectionCardHeaderClass = pageHeaderRootClass;
|
|
25
|
+
|
|
26
|
+
export const sectionCardHeaderWithGapClass = css({
|
|
27
|
+
position: "relative",
|
|
28
|
+
zIndex: "1",
|
|
29
|
+
mb: "5",
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
export const sectionCardHeaderCollapsedGapClass = css({
|
|
33
|
+
position: "relative",
|
|
34
|
+
zIndex: "1",
|
|
35
|
+
mb: "0",
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
export const sectionCardHeaderContentClass = pageHeaderContentClass;
|
|
39
|
+
|
|
40
|
+
export const sectionCardEyebrowClass = eyebrowClass;
|
|
41
|
+
|
|
42
|
+
export const sectionCardTitleClass = sectionTitleClass;
|
|
43
|
+
|
|
44
|
+
export const sectionCardDescriptionClass = css({
|
|
45
|
+
color: "text.secondary",
|
|
46
|
+
lineHeight: "1.65",
|
|
47
|
+
fontSize: "sm",
|
|
48
|
+
maxWidth: "2xl",
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
export const sectionCardActionsClass = pageHeaderActionsClass;
|
|
52
|
+
|
|
53
|
+
export const sectionCardBodyScrollClass = css({
|
|
54
|
+
position: "relative",
|
|
55
|
+
zIndex: "1",
|
|
56
|
+
minHeight: "0",
|
|
57
|
+
overflowY: "auto",
|
|
58
|
+
paddingRight: "1",
|
|
59
|
+
overscrollBehavior: "contain",
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
export const sectionCardBodyStaticClass = css({
|
|
63
|
+
position: "relative",
|
|
64
|
+
zIndex: "1",
|
|
65
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { mount } from "@vue/test-utils";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import SectionCard from "./SectionCard.vue";
|
|
4
|
+
|
|
5
|
+
describe("SectionCard", () => {
|
|
6
|
+
it("renders a configurable eyebrow and actions slot", () => {
|
|
7
|
+
const wrapper = mount(SectionCard, {
|
|
8
|
+
props: {
|
|
9
|
+
title: "Release",
|
|
10
|
+
eyebrow: "Lifecycle",
|
|
11
|
+
},
|
|
12
|
+
slots: {
|
|
13
|
+
actions: "Actions",
|
|
14
|
+
default: "Body",
|
|
15
|
+
},
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
expect(wrapper.text()).toContain("Lifecycle");
|
|
19
|
+
expect(wrapper.text()).toContain("Actions");
|
|
20
|
+
expect(wrapper.text()).toContain("Body");
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { cx } from "@styled/css";
|
|
3
|
+
import {
|
|
4
|
+
sectionCardActionsClass,
|
|
5
|
+
sectionCardBodyScrollClass,
|
|
6
|
+
sectionCardBodyStaticClass,
|
|
7
|
+
sectionCardClass,
|
|
8
|
+
sectionCardDescriptionClass,
|
|
9
|
+
sectionCardEyebrowClass,
|
|
10
|
+
sectionCardHeaderClass,
|
|
11
|
+
sectionCardHeaderCollapsedGapClass,
|
|
12
|
+
sectionCardHeaderContentClass,
|
|
13
|
+
sectionCardHeaderWithGapClass,
|
|
14
|
+
sectionCardScrollClass,
|
|
15
|
+
sectionCardSurfaceClass,
|
|
16
|
+
sectionCardTitleClass,
|
|
17
|
+
} from "./SectionCard.styles";
|
|
18
|
+
|
|
19
|
+
const props = withDefaults(defineProps<{
|
|
20
|
+
title: string;
|
|
21
|
+
eyebrow?: string;
|
|
22
|
+
description?: string;
|
|
23
|
+
scrollBody?: boolean;
|
|
24
|
+
collapseBodyGap?: boolean;
|
|
25
|
+
}>(), {
|
|
26
|
+
eyebrow: "Workspace surface",
|
|
27
|
+
description: "",
|
|
28
|
+
scrollBody: false,
|
|
29
|
+
collapseBodyGap: false,
|
|
30
|
+
});
|
|
31
|
+
</script>
|
|
32
|
+
|
|
33
|
+
<template>
|
|
34
|
+
<section :class="cx(sectionCardSurfaceClass, sectionCardClass, props.scrollBody && sectionCardScrollClass)">
|
|
35
|
+
<div :class="cx(sectionCardHeaderClass, props.collapseBodyGap ? sectionCardHeaderCollapsedGapClass : sectionCardHeaderWithGapClass)">
|
|
36
|
+
<div :class="sectionCardHeaderContentClass">
|
|
37
|
+
<p v-if="props.eyebrow" :class="sectionCardEyebrowClass">{{ props.eyebrow }}</p>
|
|
38
|
+
<h2 :class="sectionCardTitleClass">{{ props.title }}</h2>
|
|
39
|
+
<p v-if="props.description" :class="sectionCardDescriptionClass">
|
|
40
|
+
{{ props.description }}
|
|
41
|
+
</p>
|
|
42
|
+
</div>
|
|
43
|
+
<div v-if="$slots.actions" :class="sectionCardActionsClass">
|
|
44
|
+
<slot name="actions" />
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
<div :class="props.scrollBody ? sectionCardBodyScrollClass : sectionCardBodyStaticClass">
|
|
48
|
+
<slot />
|
|
49
|
+
</div>
|
|
50
|
+
</section>
|
|
51
|
+
</template>
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { cva } from "@styled/css";
|
|
2
|
+
|
|
3
|
+
export const statusBadgeClass = cva({
|
|
4
|
+
base: {
|
|
5
|
+
display: "inline-flex",
|
|
6
|
+
alignItems: "center",
|
|
7
|
+
justifyContent: "center",
|
|
8
|
+
borderRadius: "full",
|
|
9
|
+
borderWidth: "1px",
|
|
10
|
+
px: "3",
|
|
11
|
+
py: "1.5",
|
|
12
|
+
fontSize: "xs",
|
|
13
|
+
fontWeight: "700",
|
|
14
|
+
textTransform: "uppercase",
|
|
15
|
+
letterSpacing: "0.1em",
|
|
16
|
+
backdropFilter: "blur(16px) saturate(140%)",
|
|
17
|
+
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.16)",
|
|
18
|
+
_dark: {
|
|
19
|
+
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.04)",
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
variants: {
|
|
23
|
+
tone: {
|
|
24
|
+
success: {
|
|
25
|
+
bg: "badge.successBg",
|
|
26
|
+
color: "badge.successText",
|
|
27
|
+
borderColor: "badge.successBorder",
|
|
28
|
+
},
|
|
29
|
+
warning: {
|
|
30
|
+
bg: "badge.warningBg",
|
|
31
|
+
color: "badge.warningText",
|
|
32
|
+
borderColor: "badge.warningBorder",
|
|
33
|
+
},
|
|
34
|
+
info: {
|
|
35
|
+
bg: "badge.infoBg",
|
|
36
|
+
color: "badge.infoText",
|
|
37
|
+
borderColor: "badge.infoBorder",
|
|
38
|
+
},
|
|
39
|
+
neutral: {
|
|
40
|
+
bg: "badge.neutralBg",
|
|
41
|
+
color: "badge.neutralText",
|
|
42
|
+
borderColor: "badge.neutralBorder",
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
defaultVariants: {
|
|
47
|
+
tone: "neutral",
|
|
48
|
+
},
|
|
49
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { mount } from "@vue/test-utils";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import StatusBadge from "./StatusBadge.vue";
|
|
4
|
+
|
|
5
|
+
describe("StatusBadge", () => {
|
|
6
|
+
it("renders slot content", () => {
|
|
7
|
+
const wrapper = mount(StatusBadge, {
|
|
8
|
+
props: {
|
|
9
|
+
tone: "success",
|
|
10
|
+
},
|
|
11
|
+
slots: {
|
|
12
|
+
default: "Ready",
|
|
13
|
+
},
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
expect(wrapper.text()).toContain("Ready");
|
|
17
|
+
});
|
|
18
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { statusBadgeClass } from "./StatusBadge.styles";
|
|
3
|
+
|
|
4
|
+
const props = withDefaults(
|
|
5
|
+
defineProps<{
|
|
6
|
+
tone?: "success" | "warning" | "info" | "neutral";
|
|
7
|
+
}>(),
|
|
8
|
+
{
|
|
9
|
+
tone: "neutral",
|
|
10
|
+
}
|
|
11
|
+
);
|
|
12
|
+
</script>
|
|
13
|
+
|
|
14
|
+
<template>
|
|
15
|
+
<span :class="statusBadgeClass({ tone: props.tone })">
|
|
16
|
+
<slot />
|
|
17
|
+
</span>
|
|
18
|
+
</template>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { css } from "@styled/css";
|
|
2
|
+
import { buttonRecipe } from "../styles";
|
|
3
|
+
|
|
4
|
+
// buttonRecipe is the source of truth — UiButton uses it directly.
|
|
5
|
+
export const uiButtonClass = buttonRecipe;
|
|
6
|
+
|
|
7
|
+
export const uiButtonContentClass = css({
|
|
8
|
+
display: "inline-flex",
|
|
9
|
+
alignItems: "center",
|
|
10
|
+
justifyContent: "center",
|
|
11
|
+
gap: "2",
|
|
12
|
+
minWidth: "0",
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export const uiButtonHiddenContentClass = css({
|
|
16
|
+
visibility: "hidden",
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export const uiButtonBusyContentClass = css({
|
|
20
|
+
position: "absolute",
|
|
21
|
+
inset: "0",
|
|
22
|
+
display: "inline-flex",
|
|
23
|
+
alignItems: "center",
|
|
24
|
+
justifyContent: "center",
|
|
25
|
+
gap: "2",
|
|
26
|
+
px: "4",
|
|
27
|
+
py: "3",
|
|
28
|
+
pointerEvents: "none",
|
|
29
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { mount } from "@vue/test-utils";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import UiButton from "./UiButton.vue";
|
|
4
|
+
|
|
5
|
+
describe("UiButton", () => {
|
|
6
|
+
it("renders loading state accessibly", () => {
|
|
7
|
+
const wrapper = mount(UiButton, {
|
|
8
|
+
props: {
|
|
9
|
+
loading: true,
|
|
10
|
+
loadingLabel: "Saving",
|
|
11
|
+
},
|
|
12
|
+
slots: {
|
|
13
|
+
default: "Save",
|
|
14
|
+
},
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
expect(wrapper.attributes("aria-busy")).toBe("true");
|
|
18
|
+
expect(wrapper.text()).toContain("Saving");
|
|
19
|
+
expect(wrapper.attributes("disabled")).toBeDefined();
|
|
20
|
+
});
|
|
21
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { LoaderCircle } from "lucide-vue-next";
|
|
3
|
+
import {
|
|
4
|
+
uiButtonBusyContentClass,
|
|
5
|
+
uiButtonClass,
|
|
6
|
+
uiButtonContentClass,
|
|
7
|
+
uiButtonHiddenContentClass,
|
|
8
|
+
} from "./UiButton.styles";
|
|
9
|
+
|
|
10
|
+
withDefaults(
|
|
11
|
+
defineProps<{
|
|
12
|
+
variant?: "solid" | "outline" | "subtle";
|
|
13
|
+
loading?: boolean;
|
|
14
|
+
loadingLabel?: string;
|
|
15
|
+
disabled?: boolean;
|
|
16
|
+
type?: "button" | "submit";
|
|
17
|
+
ariaLabel?: string;
|
|
18
|
+
}>(),
|
|
19
|
+
{
|
|
20
|
+
variant: "solid",
|
|
21
|
+
loading: false,
|
|
22
|
+
loadingLabel: "Working...",
|
|
23
|
+
disabled: false,
|
|
24
|
+
type: "button",
|
|
25
|
+
ariaLabel: undefined,
|
|
26
|
+
}
|
|
27
|
+
);
|
|
28
|
+
</script>
|
|
29
|
+
|
|
30
|
+
<template>
|
|
31
|
+
<button
|
|
32
|
+
:type="type"
|
|
33
|
+
:disabled="disabled || loading"
|
|
34
|
+
:aria-busy="loading || undefined"
|
|
35
|
+
:aria-label="ariaLabel"
|
|
36
|
+
:class="uiButtonClass({ variant, disabled, loading })"
|
|
37
|
+
>
|
|
38
|
+
<span :class="[uiButtonContentClass, loading ? uiButtonHiddenContentClass : undefined]">
|
|
39
|
+
<slot />
|
|
40
|
+
</span>
|
|
41
|
+
<span v-if="loading" :class="uiButtonBusyContentClass">
|
|
42
|
+
<LoaderCircle :size="14" class="aggc-spin" aria-hidden="true" />
|
|
43
|
+
<span>{{ loadingLabel }}</span>
|
|
44
|
+
</span>
|
|
45
|
+
</button>
|
|
46
|
+
</template>
|