@cfasim-ui/components 0.1.6 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cfasim-ui/components",
3
- "version": "0.1.6",
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.8",
30
+ "happy-dom": "^20.8.9",
31
31
  "vitest": "^4.1.0"
32
32
  }
33
33
  }
@@ -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("/cfa-simulator/cfasim-ui/components/box");
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("/cfa-simulator/cfasim-ui/components/button");
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("/cfa-simulator/cfasim-ui/components/expander");
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();
@@ -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("/cfa-simulator/cfasim-ui/components/hint");
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();
@@ -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("/cfa-simulator/cfasim-ui/components/icon");
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("/cfa-simulator/cfasim-ui/components/number-input");
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(formatWithCommas(toDisplay(model.value)));
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 = formatWithCommas(toDisplay(v));
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 = formatWithCommas(parsed);
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
- const parsed = Number(stripCommas(local.value));
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
- sliderLocal.value = v[0];
133
- local.value = formatWithCommas(toDisplay(v[0]));
155
+ const val = coerceInteger(v[0]);
156
+ sliderLocal.value = val;
157
+ local.value = formatForDisplay(toDisplay(val));
134
158
  if (props.live) {
135
- model.value = v[0];
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 = formatWithCommas(next);
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("/cfa-simulator/cfasim-ui/components/select-box");
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 { ref, onMounted, onUnmounted } from "vue";
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
- <div class="Topbar">
70
- <slot name="topbar">
71
- <LightDarkToggle />
72
- </slot>
73
- </div>
74
- <div class="MainScroll">
75
- <div class="MainContent">
76
- <slot />
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
- </div>
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
- .SidebarLayout[data-collapsed="true"] .SidebarRail {
103
- width: var(--toggle-size);
104
- background-color: var(--color-bg-1);
105
- border-right: 1px solid var(--color-border);
106
- box-shadow: var(--shadow-sm);
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
- .SidebarLayout[data-collapsed="true"] .Toggle:not(.MobileToggle) {
183
- transform: translateX(100%);
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
- justify-content: flex-end;
200
- padding: var(--space-2) var(--space-4);
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: var(--space-2) var(--space-4) var(--space-2) var(--space-20);
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: sidebar overlays content */
240
- .Overlay {
241
- position: fixed;
242
- inset: 0;
243
- background: rgba(0, 0, 0, 0.4);
244
- z-index: 10;
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
- .SidebarLayout[data-mobile="true"] .SidebarRail {
248
- position: fixed;
249
- top: 0;
250
- left: 0;
251
- z-index: 11;
252
- width: var(--sidebar-width);
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
- .SidebarLayout[data-mobile="true"][data-collapsed="true"] .SidebarRail {
259
- transform: translateX(-100%);
260
- width: var(--sidebar-width);
261
- max-width: 85vw;
262
- background-color: transparent;
263
- border-right: none;
264
- box-shadow: none;
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
- .SidebarLayout[data-mobile="true"] .Sidebar {
268
- width: 100%;
412
+ .TabsBarEnd {
413
+ margin-left: auto;
414
+ display: flex;
415
+ align-items: center;
416
+ gap: var(--space-1);
269
417
  }
270
418
 
271
- .SidebarLayout[data-mobile="true"][data-collapsed="true"] .Sidebar {
272
- transform: translateX(0);
419
+ .TabsList {
420
+ display: flex;
421
+ gap: var(--space-1);
422
+ position: relative;
423
+ align-self: stretch;
273
424
  }
274
425
 
275
- .MobileToggle {
276
- position: fixed;
277
- top: var(--space-1);
278
- left: calc(min(var(--sidebar-width), 85vw) + var(--space-1));
279
- z-index: 12;
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
- box-shadow: none;
283
- border-radius: var(--radius-md);
284
- transition: left var(--transition-normal);
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
- .SidebarLayout[data-collapsed="true"] .MobileToggle {
288
- left: var(--space-1);
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("/cfa-simulator/cfasim-ui/components/spinner");
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("/cfa-simulator/cfasim-ui/components/text-input");
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("/cfa-simulator/cfasim-ui/components/toggle");
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";