@cfasim-ui/components 0.1.7 → 0.1.8
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/package.json +2 -2
- package/src/Box/Box.spec.ts +1 -1
- package/src/Button/Button.spec.ts +1 -1
- package/src/Expander/Expander.spec.ts +1 -1
- package/src/Hint/Hint.spec.ts +1 -1
- package/src/Icon/Icon.spec.ts +1 -1
- package/src/NumberInput/NumberInput.md +18 -0
- package/src/NumberInput/NumberInput.spec.ts +1 -1
- package/src/NumberInput/NumberInput.test.ts +105 -0
- package/src/NumberInput/NumberInput.vue +36 -11
- package/src/SelectBox/SelectBox.spec.ts +1 -1
- package/src/SidebarLayout/SidebarLayout.md +104 -0
- package/src/SidebarLayout/SidebarLayout.test.ts +86 -0
- package/src/SidebarLayout/SidebarLayout.vue +267 -92
- package/src/Spinner/Spinner.spec.ts +1 -1
- package/src/TextInput/TextInput.spec.ts +1 -1
- package/src/Toggle/Toggle.spec.ts +1 -1
- package/src/index.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cfasim-ui/components",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Vue 3 UI components for cfasim-ui",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"devDependencies": {
|
|
28
28
|
"@vitejs/plugin-vue": "^6.0.5",
|
|
29
29
|
"@vue/test-utils": "^2.4.6",
|
|
30
|
-
"happy-dom": "^20.8.
|
|
30
|
+
"happy-dom": "^20.8.9",
|
|
31
31
|
"vitest": "^4.1.0"
|
|
32
32
|
}
|
|
33
33
|
}
|
package/src/Box/Box.spec.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { test, expect } from "@playwright/test";
|
|
2
2
|
|
|
3
3
|
test("Box page renders demos", async ({ page }) => {
|
|
4
|
-
await page.goto("
|
|
4
|
+
await page.goto("./cfasim-ui/components/box");
|
|
5
5
|
await expect(page.locator("h1")).toBeVisible();
|
|
6
6
|
const demos = page.locator(".demo-preview");
|
|
7
7
|
await expect(demos.first()).toBeVisible();
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { test, expect } from "@playwright/test";
|
|
2
2
|
|
|
3
3
|
test("Button page renders demos", async ({ page }) => {
|
|
4
|
-
await page.goto("
|
|
4
|
+
await page.goto("./cfasim-ui/components/button");
|
|
5
5
|
await expect(page.locator("h1")).toBeVisible();
|
|
6
6
|
const demos = page.locator(".demo-preview");
|
|
7
7
|
await expect(demos.first()).toBeVisible();
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { test, expect } from "@playwright/test";
|
|
2
2
|
|
|
3
3
|
test("Expander page renders demos", async ({ page }) => {
|
|
4
|
-
await page.goto("
|
|
4
|
+
await page.goto("./cfasim-ui/components/expander");
|
|
5
5
|
await expect(page.locator("h1")).toBeVisible();
|
|
6
6
|
const demos = page.locator(".demo-preview");
|
|
7
7
|
await expect(demos.first()).toBeVisible();
|
package/src/Hint/Hint.spec.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { test, expect } from "@playwright/test";
|
|
2
2
|
|
|
3
3
|
test("Hint page renders demos", async ({ page }) => {
|
|
4
|
-
await page.goto("
|
|
4
|
+
await page.goto("./cfasim-ui/components/hint");
|
|
5
5
|
await expect(page.locator("h1")).toBeVisible();
|
|
6
6
|
const demos = page.locator(".demo-preview");
|
|
7
7
|
await expect(demos.first()).toBeVisible();
|
package/src/Icon/Icon.spec.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { test, expect } from "@playwright/test";
|
|
2
2
|
|
|
3
3
|
test("Icon page renders demos", async ({ page }) => {
|
|
4
|
-
await page.goto("
|
|
4
|
+
await page.goto("./cfasim-ui/components/icon");
|
|
5
5
|
await expect(page.locator("h1")).toBeVisible();
|
|
6
6
|
const demos = page.locator(".demo-preview");
|
|
7
7
|
await expect(demos.first()).toBeVisible();
|
|
@@ -166,4 +166,22 @@ With `live` on a regular input, the model updates as you type (debounced 300ms).
|
|
|
166
166
|
</template>
|
|
167
167
|
</ComponentDemo>
|
|
168
168
|
|
|
169
|
+
### Integer type
|
|
170
|
+
|
|
171
|
+
With `number-type="integer"`, decimal values are truncated to whole numbers on commit. When combined with `percent`, the display value (e.g. 42%) is treated as the integer — so internal values like 0.42 are valid.
|
|
172
|
+
|
|
173
|
+
<ComponentDemo>
|
|
174
|
+
<div style="width: 300px">
|
|
175
|
+
<NumberInput v-model="days" label="Steps" number-type="integer" />
|
|
176
|
+
</div>
|
|
177
|
+
|
|
178
|
+
<template #code>
|
|
179
|
+
|
|
180
|
+
```vue
|
|
181
|
+
<NumberInput v-model="days" label="Steps" number-type="integer" />
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
</template>
|
|
185
|
+
</ComponentDemo>
|
|
186
|
+
|
|
169
187
|
<!--@include: ./_api/number-input.md-->
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { test, expect } from "@playwright/test";
|
|
2
2
|
|
|
3
3
|
test("NumberInput page renders demos", async ({ page }) => {
|
|
4
|
-
await page.goto("
|
|
4
|
+
await page.goto("./cfasim-ui/components/number-input");
|
|
5
5
|
await expect(page.locator("h1")).toBeVisible();
|
|
6
6
|
const demos = page.locator(".demo-preview");
|
|
7
7
|
await expect(demos.first()).toBeVisible();
|
|
@@ -458,6 +458,111 @@ describe("NumberInput", () => {
|
|
|
458
458
|
expect(wrapper.props("modelValue")).toBe(1234567);
|
|
459
459
|
});
|
|
460
460
|
|
|
461
|
+
it("does not reformat while typing a decimal point", async () => {
|
|
462
|
+
const wrapper = mount(NumberInput, {
|
|
463
|
+
props: {
|
|
464
|
+
modelValue: 15,
|
|
465
|
+
label: "Count",
|
|
466
|
+
},
|
|
467
|
+
});
|
|
468
|
+
const input = wrapper.find("input");
|
|
469
|
+
// Simulate typing "15." — reformatInput should not strip the trailing dot
|
|
470
|
+
(input.element as HTMLInputElement).value = "15.";
|
|
471
|
+
await input.trigger("input");
|
|
472
|
+
expect((input.element as HTMLInputElement).value).toBe("15.");
|
|
473
|
+
|
|
474
|
+
// Typing "15.60" — trailing zero after decimal should also be preserved
|
|
475
|
+
(input.element as HTMLInputElement).value = "15.60";
|
|
476
|
+
await input.trigger("input");
|
|
477
|
+
expect((input.element as HTMLInputElement).value).toBe("15.60");
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
it("truncates decimal to integer when numberType is integer", async () => {
|
|
481
|
+
const wrapper = mount(NumberInput, {
|
|
482
|
+
props: {
|
|
483
|
+
modelValue: 15,
|
|
484
|
+
label: "Count",
|
|
485
|
+
numberType: "integer" as const,
|
|
486
|
+
"onUpdate:modelValue": (v: number | undefined) =>
|
|
487
|
+
wrapper.setProps({ modelValue: v }),
|
|
488
|
+
},
|
|
489
|
+
});
|
|
490
|
+
const input = wrapper.find("input");
|
|
491
|
+
await input.setValue("15.6");
|
|
492
|
+
await input.trigger("blur");
|
|
493
|
+
expect(wrapper.props("modelValue")).toBe(15);
|
|
494
|
+
expect((input.element as HTMLInputElement).value).toBe("15");
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
it("integer with percent allows whole percentages like 0.42", async () => {
|
|
498
|
+
const wrapper = mount(NumberInput, {
|
|
499
|
+
props: {
|
|
500
|
+
modelValue: 0.5,
|
|
501
|
+
label: "Rate",
|
|
502
|
+
percent: true,
|
|
503
|
+
numberType: "integer" as const,
|
|
504
|
+
"onUpdate:modelValue": (v: number | undefined) =>
|
|
505
|
+
wrapper.setProps({ modelValue: v }),
|
|
506
|
+
},
|
|
507
|
+
});
|
|
508
|
+
const input = wrapper.find("input");
|
|
509
|
+
// 42% → internal 0.42 (display value 42 is a whole number, so allowed)
|
|
510
|
+
await input.setValue("42");
|
|
511
|
+
await input.trigger("blur");
|
|
512
|
+
expect(wrapper.props("modelValue")).toBeCloseTo(0.42);
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
it("integer with percent truncates fractional percentages", async () => {
|
|
516
|
+
const wrapper = mount(NumberInput, {
|
|
517
|
+
props: {
|
|
518
|
+
modelValue: 0.5,
|
|
519
|
+
label: "Rate",
|
|
520
|
+
percent: true,
|
|
521
|
+
numberType: "integer" as const,
|
|
522
|
+
"onUpdate:modelValue": (v: number | undefined) =>
|
|
523
|
+
wrapper.setProps({ modelValue: v }),
|
|
524
|
+
},
|
|
525
|
+
});
|
|
526
|
+
const input = wrapper.find("input");
|
|
527
|
+
// 42.7% should truncate display to 42 → internal 0.42
|
|
528
|
+
await input.setValue("42.7");
|
|
529
|
+
await input.trigger("blur");
|
|
530
|
+
expect(wrapper.props("modelValue")).toBeCloseTo(0.42);
|
|
531
|
+
expect((input.element as HTMLInputElement).value).toBe("42");
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
it("displays .0 suffix for whole numbers when numberType is float", () => {
|
|
535
|
+
const wrapper = mount(NumberInput, {
|
|
536
|
+
props: { modelValue: 100, label: "Rate", numberType: "float" as const },
|
|
537
|
+
});
|
|
538
|
+
const input = wrapper.find("input");
|
|
539
|
+
expect((input.element as HTMLInputElement).value).toBe("100.0");
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
it("does not add .0 suffix for non-whole floats", () => {
|
|
543
|
+
const wrapper = mount(NumberInput, {
|
|
544
|
+
props: { modelValue: 2.5, label: "Rate", numberType: "float" as const },
|
|
545
|
+
});
|
|
546
|
+
const input = wrapper.find("input");
|
|
547
|
+
expect((input.element as HTMLInputElement).value).toBe("2.5");
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
it("adds .0 suffix after committing a whole number in float mode", async () => {
|
|
551
|
+
const wrapper = mount(NumberInput, {
|
|
552
|
+
props: {
|
|
553
|
+
modelValue: 2.5,
|
|
554
|
+
label: "Rate",
|
|
555
|
+
numberType: "float" as const,
|
|
556
|
+
"onUpdate:modelValue": (v: number | undefined) =>
|
|
557
|
+
wrapper.setProps({ modelValue: v }),
|
|
558
|
+
},
|
|
559
|
+
});
|
|
560
|
+
const input = wrapper.find("input");
|
|
561
|
+
await input.setValue("3");
|
|
562
|
+
await input.trigger("blur");
|
|
563
|
+
expect((input.element as HTMLInputElement).value).toBe("3.0");
|
|
564
|
+
});
|
|
565
|
+
|
|
461
566
|
it("syncs local value when model changes externally", async () => {
|
|
462
567
|
const wrapper = mount(NumberInput, {
|
|
463
568
|
props: {
|
|
@@ -15,6 +15,7 @@ const props = defineProps<{
|
|
|
15
15
|
percent?: boolean;
|
|
16
16
|
slider?: boolean;
|
|
17
17
|
live?: boolean;
|
|
18
|
+
numberType?: "integer" | "float";
|
|
18
19
|
}>();
|
|
19
20
|
|
|
20
21
|
const sliderMin = computed(() => props.min ?? (props.percent ? 0 : 0));
|
|
@@ -36,21 +37,37 @@ function fromDisplay(v: number) {
|
|
|
36
37
|
return props.percent ? v / 100 : v;
|
|
37
38
|
}
|
|
38
39
|
|
|
40
|
+
function coerceInteger(v: number): number {
|
|
41
|
+
if (props.numberType !== "integer") return v;
|
|
42
|
+
// Truncate the display value to an integer, then convert back
|
|
43
|
+
const display = toDisplay(v);
|
|
44
|
+
if (display == null) return v;
|
|
45
|
+
return fromDisplay(Math.trunc(display));
|
|
46
|
+
}
|
|
47
|
+
|
|
39
48
|
function formatWithCommas(v: number | undefined): string {
|
|
40
49
|
if (v == null) return "";
|
|
41
50
|
return v.toLocaleString("en-US");
|
|
42
51
|
}
|
|
43
52
|
|
|
53
|
+
function formatForDisplay(v: number | undefined): string {
|
|
54
|
+
const s = formatWithCommas(v);
|
|
55
|
+
if (props.numberType === "float" && v != null && Number.isInteger(v)) {
|
|
56
|
+
return s + ".0";
|
|
57
|
+
}
|
|
58
|
+
return s;
|
|
59
|
+
}
|
|
60
|
+
|
|
44
61
|
function stripCommas(s: string): string {
|
|
45
62
|
return s.replace(/,/g, "");
|
|
46
63
|
}
|
|
47
64
|
|
|
48
|
-
const local = ref(
|
|
65
|
+
const local = ref(formatForDisplay(toDisplay(model.value)));
|
|
49
66
|
const sliderLocal = ref(model.value);
|
|
50
67
|
const validationError = ref<string>();
|
|
51
68
|
|
|
52
69
|
watch(model, (v) => {
|
|
53
|
-
local.value =
|
|
70
|
+
local.value = formatForDisplay(toDisplay(v));
|
|
54
71
|
sliderLocal.value = v;
|
|
55
72
|
});
|
|
56
73
|
|
|
@@ -58,6 +75,7 @@ function reformatInput(event: Event) {
|
|
|
58
75
|
const input = event.target as HTMLInputElement;
|
|
59
76
|
const raw = stripCommas(input.value);
|
|
60
77
|
if (raw === "" || raw === "-") return;
|
|
78
|
+
if (raw.endsWith(".") || (raw.includes(".") && raw.endsWith("0"))) return;
|
|
61
79
|
const parsed = Number(raw);
|
|
62
80
|
if (Number.isNaN(parsed)) return;
|
|
63
81
|
|
|
@@ -89,7 +107,7 @@ function onBlur() {
|
|
|
89
107
|
commit();
|
|
90
108
|
const parsed = Number(stripCommas(local.value));
|
|
91
109
|
if (!Number.isNaN(parsed)) {
|
|
92
|
-
local.value =
|
|
110
|
+
local.value = formatForDisplay(parsed);
|
|
93
111
|
}
|
|
94
112
|
}
|
|
95
113
|
|
|
@@ -116,9 +134,14 @@ function validate(displayValue: number): string | undefined {
|
|
|
116
134
|
}
|
|
117
135
|
|
|
118
136
|
function commit() {
|
|
119
|
-
|
|
137
|
+
let parsed = Number(stripCommas(local.value));
|
|
120
138
|
if (Number.isNaN(parsed)) return;
|
|
121
139
|
|
|
140
|
+
if (props.numberType === "integer") {
|
|
141
|
+
parsed = Math.trunc(parsed);
|
|
142
|
+
local.value = formatForDisplay(parsed);
|
|
143
|
+
}
|
|
144
|
+
|
|
122
145
|
const error = validate(parsed);
|
|
123
146
|
validationError.value = error;
|
|
124
147
|
if (error) return;
|
|
@@ -129,16 +152,17 @@ function commit() {
|
|
|
129
152
|
|
|
130
153
|
function onSliderUpdate(v: number[] | undefined) {
|
|
131
154
|
if (!v) return;
|
|
132
|
-
|
|
133
|
-
|
|
155
|
+
const val = coerceInteger(v[0]);
|
|
156
|
+
sliderLocal.value = val;
|
|
157
|
+
local.value = formatForDisplay(toDisplay(val));
|
|
134
158
|
if (props.live) {
|
|
135
|
-
model.value =
|
|
159
|
+
model.value = val;
|
|
136
160
|
}
|
|
137
161
|
}
|
|
138
162
|
|
|
139
163
|
function onSliderCommit(v: number[] | undefined) {
|
|
140
164
|
if (!v) return;
|
|
141
|
-
model.value = v[0];
|
|
165
|
+
model.value = coerceInteger(v[0]);
|
|
142
166
|
}
|
|
143
167
|
|
|
144
168
|
const inputStep = computed(() => {
|
|
@@ -152,9 +176,10 @@ function onArrowStep(event: KeyboardEvent, direction: 1 | -1) {
|
|
|
152
176
|
const current = Number.isNaN(parsed) ? 0 : parsed;
|
|
153
177
|
const step = inputStep.value * (event.shiftKey ? 10 : 1);
|
|
154
178
|
let next = current + step * direction;
|
|
179
|
+
if (props.numberType === "integer") next = Math.trunc(next);
|
|
155
180
|
if (inputMin.value != null) next = Math.max(next, inputMin.value);
|
|
156
181
|
if (inputMax.value != null) next = Math.min(next, inputMax.value);
|
|
157
|
-
local.value =
|
|
182
|
+
local.value = formatForDisplay(next);
|
|
158
183
|
validationError.value = undefined;
|
|
159
184
|
model.value = fromDisplay(next);
|
|
160
185
|
sliderLocal.value = model.value;
|
|
@@ -179,7 +204,7 @@ const inputMax = computed(() => {
|
|
|
179
204
|
<span v-if="!props.slider" class="input-wrapper">
|
|
180
205
|
<input
|
|
181
206
|
type="text"
|
|
182
|
-
inputmode="decimal"
|
|
207
|
+
:inputmode="props.numberType === 'integer' ? 'numeric' : 'decimal'"
|
|
183
208
|
v-model="local"
|
|
184
209
|
:placeholder="props.placeholder"
|
|
185
210
|
:aria-invalid="!!validationError"
|
|
@@ -227,7 +252,7 @@ const inputMax = computed(() => {
|
|
|
227
252
|
<span v-if="!props.slider" class="input-wrapper">
|
|
228
253
|
<input
|
|
229
254
|
type="text"
|
|
230
|
-
inputmode="decimal"
|
|
255
|
+
:inputmode="props.numberType === 'integer' ? 'numeric' : 'decimal'"
|
|
231
256
|
v-model="local"
|
|
232
257
|
:placeholder="props.placeholder"
|
|
233
258
|
:aria-invalid="!!validationError"
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { test, expect } from "@playwright/test";
|
|
2
2
|
|
|
3
3
|
test("SelectBox page renders demos", async ({ page }) => {
|
|
4
|
-
await page.goto("
|
|
4
|
+
await page.goto("./cfasim-ui/components/select-box");
|
|
5
5
|
await expect(page.locator("h1")).toBeVisible();
|
|
6
6
|
const demos = page.locator(".demo-preview");
|
|
7
7
|
await expect(demos.first()).toBeVisible();
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# SidebarLayout
|
|
2
|
+
|
|
3
|
+
A responsive two-panel layout with a collapsible sidebar and main content area. On mobile, the sidebar becomes an overlay.
|
|
4
|
+
|
|
5
|
+
## Demo
|
|
6
|
+
|
|
7
|
+
<a href="/cfa-simulator/docs/demos/sidebar-layout/index.html" target="_blank">Open in full window ↗</a>
|
|
8
|
+
|
|
9
|
+
<div style="border: 1px solid var(--vp-c-border); border-radius: 8px; overflow: hidden; height: 500px;">
|
|
10
|
+
<iframe src="/cfa-simulator/docs/demos/sidebar-layout/index.html" style="width: 100%; height: 100%; border: none;" />
|
|
11
|
+
</div>
|
|
12
|
+
|
|
13
|
+
## Tabs Demo (Router Mode)
|
|
14
|
+
|
|
15
|
+
<a href="/cfa-simulator/docs/demos/sidebar-tabs/index.html" target="_blank">Open in full window ↗</a>
|
|
16
|
+
|
|
17
|
+
<div style="border: 1px solid var(--vp-c-border); border-radius: 8px; overflow: hidden; height: 500px;">
|
|
18
|
+
<iframe src="/cfa-simulator/docs/demos/sidebar-tabs/index.html" style="width: 100%; height: 100%; border: none;" />
|
|
19
|
+
</div>
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
```vue
|
|
24
|
+
<SidebarLayout>
|
|
25
|
+
<template #sidebar>
|
|
26
|
+
<h2>Controls</h2>
|
|
27
|
+
<NumberInput v-model="value" label="Parameter" slider live />
|
|
28
|
+
</template>
|
|
29
|
+
<h1>Main Content</h1>
|
|
30
|
+
<p>Your charts and data go here.</p>
|
|
31
|
+
</SidebarLayout>
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Slots
|
|
35
|
+
|
|
36
|
+
| Slot | Description |
|
|
37
|
+
| --------- | ------------------------------------------ |
|
|
38
|
+
| `sidebar` | Content rendered in the left sidebar panel |
|
|
39
|
+
| `default` | Main content area |
|
|
40
|
+
|
|
41
|
+
## Props
|
|
42
|
+
|
|
43
|
+
| Prop | Type | Default | Description |
|
|
44
|
+
| ------------- | --------- | ----------- | ---------------------------------------------------- |
|
|
45
|
+
| `hideTopbar` | `boolean` | `false` | Hides the topbar that contains the light/dark toggle |
|
|
46
|
+
| `tabs` | `Tab[]` | `undefined` | Array of tab definitions to render in the main area |
|
|
47
|
+
| `v-model:tab` | `string` | `undefined` | The active tab value (two-way binding) |
|
|
48
|
+
|
|
49
|
+
### Tab type
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
interface Tab {
|
|
53
|
+
value: string; // unique identifier
|
|
54
|
+
label: string; // display text
|
|
55
|
+
to?: string; // optional route path for vue-router integration
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Tabs
|
|
60
|
+
|
|
61
|
+
When the `tabs` prop is provided, a tab bar renders at the top of the main content area. Tabs support two modes:
|
|
62
|
+
|
|
63
|
+
### Local mode
|
|
64
|
+
|
|
65
|
+
Use `v-model:tab` to control which tab is active. Render content conditionally in the default slot.
|
|
66
|
+
|
|
67
|
+
```vue
|
|
68
|
+
<script setup>
|
|
69
|
+
import { ref } from "vue";
|
|
70
|
+
const activeTab = ref("chart");
|
|
71
|
+
</script>
|
|
72
|
+
|
|
73
|
+
<SidebarLayout
|
|
74
|
+
v-model:tab="activeTab"
|
|
75
|
+
:tabs="[
|
|
76
|
+
{ value: 'chart', label: 'Chart' },
|
|
77
|
+
{ value: 'data', label: 'Data' },
|
|
78
|
+
]"
|
|
79
|
+
>
|
|
80
|
+
<template #sidebar>
|
|
81
|
+
<h2>Controls</h2>
|
|
82
|
+
</template>
|
|
83
|
+
<div v-if="activeTab === 'chart'">Chart content</div>
|
|
84
|
+
<div v-if="activeTab === 'data'">Data table</div>
|
|
85
|
+
</SidebarLayout>
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Router mode
|
|
89
|
+
|
|
90
|
+
When tabs include a `to` property and vue-router is installed, clicking a tab navigates to that route. The active tab is automatically determined from the current route.
|
|
91
|
+
|
|
92
|
+
```vue
|
|
93
|
+
<SidebarLayout
|
|
94
|
+
:tabs="[
|
|
95
|
+
{ value: 'chart', label: 'Chart', to: '/model/chart' },
|
|
96
|
+
{ value: 'data', label: 'Data', to: '/model/data' },
|
|
97
|
+
]"
|
|
98
|
+
>
|
|
99
|
+
<template #sidebar>
|
|
100
|
+
<h2>Controls</h2>
|
|
101
|
+
</template>
|
|
102
|
+
<RouterView />
|
|
103
|
+
</SidebarLayout>
|
|
104
|
+
```
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { mount } from "@vue/test-utils";
|
|
3
|
+
import SidebarLayout from "./SidebarLayout.vue";
|
|
4
|
+
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
// SidebarLayout uses window.matchMedia
|
|
7
|
+
window.matchMedia = vi.fn().mockReturnValue({
|
|
8
|
+
matches: false,
|
|
9
|
+
addEventListener: vi.fn(),
|
|
10
|
+
removeEventListener: vi.fn(),
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const tabs = [
|
|
15
|
+
{ value: "chart", label: "Chart" },
|
|
16
|
+
{ value: "data", label: "Data" },
|
|
17
|
+
{ value: "table", label: "Table" },
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
describe("SidebarLayout tabs", () => {
|
|
21
|
+
it("renders without tabs by default", () => {
|
|
22
|
+
const wrapper = mount(SidebarLayout);
|
|
23
|
+
expect(wrapper.find("[role='tablist']").exists()).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("renders tab triggers when tabs prop is provided", () => {
|
|
27
|
+
const wrapper = mount(SidebarLayout, { props: { tabs } });
|
|
28
|
+
const triggers = wrapper.findAll("[role='tab']");
|
|
29
|
+
expect(triggers).toHaveLength(3);
|
|
30
|
+
expect(triggers[0].text()).toBe("Chart");
|
|
31
|
+
expect(triggers[1].text()).toBe("Data");
|
|
32
|
+
expect(triggers[2].text()).toBe("Table");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("defaults to first tab as active", () => {
|
|
36
|
+
const wrapper = mount(SidebarLayout, { props: { tabs } });
|
|
37
|
+
const triggers = wrapper.findAll("[role='tab']");
|
|
38
|
+
expect(triggers[0].attributes("data-state")).toBe("active");
|
|
39
|
+
expect(triggers[1].attributes("data-state")).toBe("inactive");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("activates tab matching v-model:tab", () => {
|
|
43
|
+
const wrapper = mount(SidebarLayout, {
|
|
44
|
+
props: { tabs, tab: "data" },
|
|
45
|
+
});
|
|
46
|
+
const triggers = wrapper.findAll("[role='tab']");
|
|
47
|
+
expect(triggers[1].attributes("data-state")).toBe("active");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("emits update:tab when a tab is clicked", async () => {
|
|
51
|
+
let updated: string | undefined;
|
|
52
|
+
const wrapper = mount(SidebarLayout, {
|
|
53
|
+
props: {
|
|
54
|
+
tabs,
|
|
55
|
+
tab: "chart",
|
|
56
|
+
"onUpdate:tab": (v: string | undefined) => {
|
|
57
|
+
updated = v;
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
const triggers = wrapper.findAll("[role='tab']");
|
|
62
|
+
// reka-ui triggers need mousedown for activation in happy-dom
|
|
63
|
+
await triggers[2].trigger("mousedown");
|
|
64
|
+
await triggers[2].trigger("focus");
|
|
65
|
+
await triggers[2].trigger("mouseup");
|
|
66
|
+
await triggers[2].trigger("click");
|
|
67
|
+
expect(updated).toBe("table");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("renders default slot content inside tabbed layout", () => {
|
|
71
|
+
const wrapper = mount(SidebarLayout, {
|
|
72
|
+
props: { tabs },
|
|
73
|
+
slots: { default: '<p class="test-content">Hello</p>' },
|
|
74
|
+
});
|
|
75
|
+
expect(wrapper.find(".test-content").text()).toBe("Hello");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("renders sidebar slot alongside tabs", () => {
|
|
79
|
+
const wrapper = mount(SidebarLayout, {
|
|
80
|
+
props: { tabs },
|
|
81
|
+
slots: { sidebar: '<div class="sidebar-content">Sidebar</div>' },
|
|
82
|
+
});
|
|
83
|
+
expect(wrapper.find(".sidebar-content").text()).toBe("Sidebar");
|
|
84
|
+
expect(wrapper.findAll("[role='tab']")).toHaveLength(3);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -1,8 +1,35 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
ref,
|
|
4
|
+
computed,
|
|
5
|
+
watch,
|
|
6
|
+
onMounted,
|
|
7
|
+
onUnmounted,
|
|
8
|
+
getCurrentInstance,
|
|
9
|
+
} from "vue";
|
|
10
|
+
import { TabsRoot, TabsList, TabsTrigger, TabsIndicator } from "reka-ui";
|
|
3
11
|
import Icon from "../Icon/Icon.vue";
|
|
4
12
|
import LightDarkToggle from "../LightDarkToggle/LightDarkToggle.vue";
|
|
5
13
|
|
|
14
|
+
// Optional vue-router integration (no hard dependency).
|
|
15
|
+
// $router/$route on globalProperties is vue-router's stable public API.
|
|
16
|
+
const instance = getCurrentInstance();
|
|
17
|
+
const router = instance?.appContext.config.globalProperties.$router;
|
|
18
|
+
const route = instance?.appContext.config.globalProperties.$route;
|
|
19
|
+
|
|
20
|
+
export interface Tab {
|
|
21
|
+
value: string;
|
|
22
|
+
label: string;
|
|
23
|
+
to?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const props = defineProps<{
|
|
27
|
+
hideTopbar?: boolean;
|
|
28
|
+
tabs?: Tab[];
|
|
29
|
+
}>();
|
|
30
|
+
|
|
31
|
+
const tab = defineModel<string>("tab");
|
|
32
|
+
|
|
6
33
|
const mql = window.matchMedia("(max-width: 767px)");
|
|
7
34
|
const isMobile = ref(mql.matches);
|
|
8
35
|
const collapsed = ref(mql.matches);
|
|
@@ -23,65 +50,143 @@ onUnmounted(() => {
|
|
|
23
50
|
function toggle() {
|
|
24
51
|
collapsed.value = !collapsed.value;
|
|
25
52
|
}
|
|
53
|
+
|
|
54
|
+
const isRouterMode = computed(() => !!router && props.tabs?.some((t) => t.to));
|
|
55
|
+
|
|
56
|
+
const activeTab = computed({
|
|
57
|
+
get() {
|
|
58
|
+
return tab.value ?? props.tabs?.[0]?.value;
|
|
59
|
+
},
|
|
60
|
+
set(value: string | undefined) {
|
|
61
|
+
if (!value) return;
|
|
62
|
+
tab.value = value;
|
|
63
|
+
if (isRouterMode.value && router) {
|
|
64
|
+
const target = props.tabs?.find((t) => t.value === value);
|
|
65
|
+
if (target?.to) router.push(target.to);
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// Sync active tab from route changes in router mode
|
|
71
|
+
if (route) {
|
|
72
|
+
watch(
|
|
73
|
+
() => route.path,
|
|
74
|
+
() => {
|
|
75
|
+
if (isRouterMode.value) {
|
|
76
|
+
const match = props.tabs?.find((t) => t.to === route.path);
|
|
77
|
+
if (match) tab.value = match.value;
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
{ immediate: true },
|
|
81
|
+
);
|
|
82
|
+
}
|
|
26
83
|
</script>
|
|
27
84
|
|
|
28
85
|
<template>
|
|
29
|
-
<div
|
|
30
|
-
class="SidebarLayout"
|
|
31
|
-
:data-collapsed="collapsed"
|
|
32
|
-
:data-mobile="isMobile"
|
|
33
|
-
>
|
|
34
|
-
<div v-if="isMobile && !collapsed" class="Overlay" @click="toggle" />
|
|
35
|
-
<button
|
|
36
|
-
v-if="isMobile"
|
|
37
|
-
type="button"
|
|
38
|
-
class="Toggle MobileToggle"
|
|
39
|
-
:aria-label="collapsed ? 'Expand sidebar' : 'Collapse sidebar'"
|
|
40
|
-
:title="collapsed ? 'Expand sidebar' : 'Collapse sidebar'"
|
|
41
|
-
@click="toggle"
|
|
42
|
-
>
|
|
43
|
-
<Icon
|
|
44
|
-
:icon="collapsed ? 'left_panel_open' : 'left_panel_close'"
|
|
45
|
-
size="sm"
|
|
46
|
-
/>
|
|
47
|
-
</button>
|
|
86
|
+
<div class="SidebarLayout" :data-collapsed="collapsed">
|
|
48
87
|
<div class="SidebarRail">
|
|
49
88
|
<aside class="Sidebar">
|
|
50
89
|
<div class="SidebarScroll">
|
|
90
|
+
<div class="SidebarHeader">
|
|
91
|
+
<button
|
|
92
|
+
type="button"
|
|
93
|
+
class="Toggle"
|
|
94
|
+
aria-label="Collapse sidebar"
|
|
95
|
+
title="Collapse sidebar"
|
|
96
|
+
@click="toggle"
|
|
97
|
+
>
|
|
98
|
+
<Icon icon="keyboard_double_arrow_left" size="sm" />
|
|
99
|
+
</button>
|
|
100
|
+
</div>
|
|
51
101
|
<slot name="sidebar" />
|
|
52
102
|
</div>
|
|
53
|
-
<button
|
|
54
|
-
v-if="!isMobile"
|
|
55
|
-
type="button"
|
|
56
|
-
class="Toggle"
|
|
57
|
-
:aria-label="collapsed ? 'Expand sidebar' : 'Collapse sidebar'"
|
|
58
|
-
:title="collapsed ? 'Expand sidebar' : 'Collapse sidebar'"
|
|
59
|
-
@click="toggle"
|
|
60
|
-
>
|
|
61
|
-
<Icon
|
|
62
|
-
:icon="collapsed ? 'left_panel_open' : 'left_panel_close'"
|
|
63
|
-
size="sm"
|
|
64
|
-
/>
|
|
65
|
-
</button>
|
|
66
103
|
</aside>
|
|
104
|
+
<button
|
|
105
|
+
type="button"
|
|
106
|
+
class="Toggle Toggle--expand"
|
|
107
|
+
aria-label="Expand sidebar"
|
|
108
|
+
title="Expand sidebar"
|
|
109
|
+
@click="toggle"
|
|
110
|
+
>
|
|
111
|
+
<Icon icon="keyboard_double_arrow_right" size="sm" />
|
|
112
|
+
</button>
|
|
67
113
|
</div>
|
|
68
114
|
<main class="Main">
|
|
69
|
-
<
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
<div class="
|
|
76
|
-
<
|
|
115
|
+
<TabsRoot
|
|
116
|
+
v-if="tabs?.length"
|
|
117
|
+
:model-value="activeTab"
|
|
118
|
+
class="TabsLayout"
|
|
119
|
+
@update:model-value="activeTab = $event as string"
|
|
120
|
+
>
|
|
121
|
+
<div class="TabsBar">
|
|
122
|
+
<button
|
|
123
|
+
v-if="isMobile && collapsed"
|
|
124
|
+
type="button"
|
|
125
|
+
class="Toggle"
|
|
126
|
+
aria-label="Expand sidebar"
|
|
127
|
+
title="Expand sidebar"
|
|
128
|
+
@click="toggle"
|
|
129
|
+
>
|
|
130
|
+
<Icon icon="keyboard_double_arrow_right" size="sm" />
|
|
131
|
+
</button>
|
|
132
|
+
<TabsList class="TabsList" aria-label="Tabs">
|
|
133
|
+
<TabsTrigger
|
|
134
|
+
v-for="t in tabs"
|
|
135
|
+
:key="t.value"
|
|
136
|
+
:value="t.value"
|
|
137
|
+
class="TabsTrigger"
|
|
138
|
+
>
|
|
139
|
+
{{ t.label }}
|
|
140
|
+
</TabsTrigger>
|
|
141
|
+
<TabsIndicator
|
|
142
|
+
class="TabsIndicator"
|
|
143
|
+
:style="{
|
|
144
|
+
width: 'var(--reka-tabs-indicator-size)',
|
|
145
|
+
left: 'var(--reka-tabs-indicator-position)',
|
|
146
|
+
}"
|
|
147
|
+
/>
|
|
148
|
+
</TabsList>
|
|
149
|
+
<div class="TabsBarEnd">
|
|
150
|
+
<slot name="topbar" />
|
|
151
|
+
<LightDarkToggle v-if="!hideTopbar" />
|
|
152
|
+
</div>
|
|
153
|
+
</div>
|
|
154
|
+
<div class="MainScroll">
|
|
155
|
+
<div class="MainContent">
|
|
156
|
+
<slot />
|
|
157
|
+
</div>
|
|
158
|
+
</div>
|
|
159
|
+
</TabsRoot>
|
|
160
|
+
<template v-else>
|
|
161
|
+
<div class="Topbar">
|
|
162
|
+
<button
|
|
163
|
+
v-if="isMobile && collapsed"
|
|
164
|
+
type="button"
|
|
165
|
+
class="Toggle"
|
|
166
|
+
aria-label="Expand sidebar"
|
|
167
|
+
title="Expand sidebar"
|
|
168
|
+
@click="toggle"
|
|
169
|
+
>
|
|
170
|
+
<Icon icon="keyboard_double_arrow_right" size="sm" />
|
|
171
|
+
</button>
|
|
172
|
+
<div class="TopbarEnd">
|
|
173
|
+
<slot name="topbar" />
|
|
174
|
+
<LightDarkToggle v-if="!hideTopbar" />
|
|
175
|
+
</div>
|
|
77
176
|
</div>
|
|
78
|
-
|
|
177
|
+
<div class="MainScroll">
|
|
178
|
+
<div class="MainContent">
|
|
179
|
+
<slot />
|
|
180
|
+
</div>
|
|
181
|
+
</div>
|
|
182
|
+
</template>
|
|
79
183
|
</main>
|
|
80
184
|
</div>
|
|
81
185
|
</template>
|
|
82
186
|
|
|
83
187
|
<style scoped>
|
|
84
188
|
.SidebarLayout {
|
|
189
|
+
--bar-height: 3rem;
|
|
85
190
|
display: flex;
|
|
86
191
|
height: 100vh;
|
|
87
192
|
height: 100dvh;
|
|
@@ -97,13 +202,16 @@ function toggle() {
|
|
|
97
202
|
height: 100%;
|
|
98
203
|
overflow: hidden;
|
|
99
204
|
transition: width var(--transition-normal);
|
|
205
|
+
position: relative;
|
|
100
206
|
}
|
|
101
207
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
208
|
+
@media (min-width: 768px) {
|
|
209
|
+
.SidebarLayout[data-collapsed="true"] .SidebarRail {
|
|
210
|
+
width: var(--toggle-size);
|
|
211
|
+
background-color: var(--color-bg-1);
|
|
212
|
+
border-right: 1px solid var(--color-border);
|
|
213
|
+
box-shadow: var(--shadow-sm);
|
|
214
|
+
}
|
|
107
215
|
}
|
|
108
216
|
|
|
109
217
|
.Sidebar {
|
|
@@ -179,8 +287,26 @@ function toggle() {
|
|
|
179
287
|
box-shadow: var(--shadow-focus);
|
|
180
288
|
}
|
|
181
289
|
|
|
182
|
-
.
|
|
183
|
-
|
|
290
|
+
.SidebarHeader {
|
|
291
|
+
display: flex;
|
|
292
|
+
justify-content: flex-end;
|
|
293
|
+
margin: calc(-1 * var(--space-4)) calc(-1 * var(--space-4))
|
|
294
|
+
calc(-1 * var(--space-3));
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
.Toggle--expand {
|
|
298
|
+
position: absolute;
|
|
299
|
+
top: 0;
|
|
300
|
+
left: 50%;
|
|
301
|
+
transform: translateX(-50%);
|
|
302
|
+
opacity: 0;
|
|
303
|
+
pointer-events: none;
|
|
304
|
+
transition: opacity var(--transition-fast);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
.SidebarLayout[data-collapsed="true"] .Toggle--expand {
|
|
308
|
+
opacity: 1;
|
|
309
|
+
pointer-events: auto;
|
|
184
310
|
}
|
|
185
311
|
|
|
186
312
|
.Main {
|
|
@@ -196,14 +322,21 @@ function toggle() {
|
|
|
196
322
|
.Topbar {
|
|
197
323
|
display: flex;
|
|
198
324
|
align-items: center;
|
|
199
|
-
|
|
200
|
-
padding:
|
|
325
|
+
min-height: var(--bar-height);
|
|
326
|
+
padding: 0 var(--space-4);
|
|
201
327
|
flex-shrink: 0;
|
|
202
328
|
}
|
|
203
329
|
|
|
330
|
+
.TopbarEnd {
|
|
331
|
+
margin-left: auto;
|
|
332
|
+
display: flex;
|
|
333
|
+
align-items: center;
|
|
334
|
+
gap: var(--space-1);
|
|
335
|
+
}
|
|
336
|
+
|
|
204
337
|
@media (min-width: 768px) {
|
|
205
338
|
.Topbar {
|
|
206
|
-
padding:
|
|
339
|
+
padding: 0 var(--space-4) 0 var(--space-20);
|
|
207
340
|
}
|
|
208
341
|
}
|
|
209
342
|
|
|
@@ -226,65 +359,107 @@ function toggle() {
|
|
|
226
359
|
padding: 0 var(--space-4);
|
|
227
360
|
}
|
|
228
361
|
|
|
229
|
-
.SidebarLayout[data-mobile="true"] .MainScroll {
|
|
230
|
-
padding-top: calc(var(--toggle-size) + var(--space-2));
|
|
231
|
-
}
|
|
232
|
-
|
|
233
362
|
@media (min-width: 768px) {
|
|
234
363
|
.MainContent {
|
|
235
364
|
padding: 0 var(--space-4) 0 var(--space-20);
|
|
236
365
|
}
|
|
237
366
|
}
|
|
238
367
|
|
|
239
|
-
/* Mobile:
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
368
|
+
/* Mobile: use transform instead of width resize */
|
|
369
|
+
@media (max-width: 767px) {
|
|
370
|
+
.SidebarLayout {
|
|
371
|
+
transition: transform var(--transition-normal);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
.SidebarLayout[data-collapsed="true"] {
|
|
375
|
+
transform: translateX(calc(-1 * var(--sidebar-width)));
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
.SidebarLayout[data-collapsed="true"] .Sidebar {
|
|
379
|
+
transform: translateX(0);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
.SidebarRail {
|
|
383
|
+
min-width: var(--sidebar-width);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
.Main {
|
|
387
|
+
min-width: 100vw;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
.Toggle--expand {
|
|
391
|
+
display: none;
|
|
392
|
+
}
|
|
245
393
|
}
|
|
246
394
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
max-width: 85vw;
|
|
254
|
-
transition: transform var(--transition-normal);
|
|
255
|
-
transform: translateX(0);
|
|
395
|
+
/* Tabs */
|
|
396
|
+
.TabsLayout {
|
|
397
|
+
display: flex;
|
|
398
|
+
flex-direction: column;
|
|
399
|
+
flex: 1;
|
|
400
|
+
min-height: 0;
|
|
256
401
|
}
|
|
257
402
|
|
|
258
|
-
.
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
border-
|
|
264
|
-
|
|
403
|
+
.TabsBar {
|
|
404
|
+
flex-shrink: 0;
|
|
405
|
+
display: flex;
|
|
406
|
+
align-items: center;
|
|
407
|
+
min-height: var(--bar-height);
|
|
408
|
+
border-bottom: 1px solid var(--color-border);
|
|
409
|
+
padding: 0 var(--space-4);
|
|
265
410
|
}
|
|
266
411
|
|
|
267
|
-
.
|
|
268
|
-
|
|
412
|
+
.TabsBarEnd {
|
|
413
|
+
margin-left: auto;
|
|
414
|
+
display: flex;
|
|
415
|
+
align-items: center;
|
|
416
|
+
gap: var(--space-1);
|
|
269
417
|
}
|
|
270
418
|
|
|
271
|
-
.
|
|
272
|
-
|
|
419
|
+
.TabsList {
|
|
420
|
+
display: flex;
|
|
421
|
+
gap: var(--space-1);
|
|
422
|
+
position: relative;
|
|
423
|
+
align-self: stretch;
|
|
273
424
|
}
|
|
274
425
|
|
|
275
|
-
.
|
|
276
|
-
position:
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
426
|
+
.TabsTrigger {
|
|
427
|
+
position: relative;
|
|
428
|
+
padding: var(--space-2) var(--space-3);
|
|
429
|
+
font-size: var(--font-size-sm);
|
|
430
|
+
font-weight: 500;
|
|
431
|
+
font-family: inherit;
|
|
432
|
+
color: var(--color-text-secondary);
|
|
280
433
|
background: none;
|
|
281
434
|
border: none;
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
435
|
+
cursor: pointer;
|
|
436
|
+
transition:
|
|
437
|
+
color var(--transition-fast),
|
|
438
|
+
background-color var(--transition-fast);
|
|
439
|
+
border-radius: var(--radius-md) var(--radius-md) 0 0;
|
|
285
440
|
}
|
|
286
441
|
|
|
287
|
-
.
|
|
288
|
-
|
|
442
|
+
.TabsTrigger:hover {
|
|
443
|
+
color: var(--color-text);
|
|
444
|
+
background-color: var(--color-bg-1);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
.TabsTrigger[data-state="active"] {
|
|
448
|
+
color: var(--color-text);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
.TabsTrigger:focus-visible {
|
|
452
|
+
outline: none;
|
|
453
|
+
box-shadow: var(--shadow-focus);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
.TabsIndicator {
|
|
457
|
+
position: absolute;
|
|
458
|
+
bottom: 0;
|
|
459
|
+
height: 2px;
|
|
460
|
+
background-color: var(--color-text);
|
|
461
|
+
transition:
|
|
462
|
+
width var(--transition-fast),
|
|
463
|
+
left var(--transition-fast);
|
|
289
464
|
}
|
|
290
465
|
</style>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { test, expect } from "@playwright/test";
|
|
2
2
|
|
|
3
3
|
test("Spinner page renders demos", async ({ page }) => {
|
|
4
|
-
await page.goto("
|
|
4
|
+
await page.goto("./cfasim-ui/components/spinner");
|
|
5
5
|
await expect(page.locator("h1")).toBeVisible();
|
|
6
6
|
const demos = page.locator(".demo-preview");
|
|
7
7
|
await expect(demos.first()).toBeVisible();
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { test, expect } from "@playwright/test";
|
|
2
2
|
|
|
3
3
|
test("TextInput page renders demos", async ({ page }) => {
|
|
4
|
-
await page.goto("
|
|
4
|
+
await page.goto("./cfasim-ui/components/text-input");
|
|
5
5
|
await expect(page.locator("h1")).toBeVisible();
|
|
6
6
|
const demos = page.locator(".demo-preview");
|
|
7
7
|
await expect(demos.first()).toBeVisible();
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { test, expect } from "@playwright/test";
|
|
2
2
|
|
|
3
3
|
test("Toggle page renders demos", async ({ page }) => {
|
|
4
|
-
await page.goto("
|
|
4
|
+
await page.goto("./cfasim-ui/components/toggle");
|
|
5
5
|
await expect(page.locator("h1")).toBeVisible();
|
|
6
6
|
const demos = page.locator(".demo-preview");
|
|
7
7
|
await expect(demos.first()).toBeVisible();
|
package/src/index.ts
CHANGED
|
@@ -9,6 +9,7 @@ export { default as NumberInput } from "./NumberInput/NumberInput.vue";
|
|
|
9
9
|
export { default as SelectBox } from "./SelectBox/SelectBox.vue";
|
|
10
10
|
export type { SelectOption } from "./SelectBox/SelectBox.vue";
|
|
11
11
|
export { default as SidebarLayout } from "./SidebarLayout/SidebarLayout.vue";
|
|
12
|
+
export type { Tab } from "./SidebarLayout/SidebarLayout.vue";
|
|
12
13
|
export { default as Spinner } from "./Spinner/Spinner.vue";
|
|
13
14
|
export { default as TextInput } from "./TextInput/TextInput.vue";
|
|
14
15
|
export { default as Toggle } from "./Toggle/Toggle.vue";
|