@cfasim-ui/components 0.1.0

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.
Files changed (41) hide show
  1. package/LICENSE +201 -0
  2. package/package.json +30 -0
  3. package/src/Box/Box.md +41 -0
  4. package/src/Box/Box.spec.ts +13 -0
  5. package/src/Box/Box.test.ts +49 -0
  6. package/src/Box/Box.vue +52 -0
  7. package/src/Button/Button.md +55 -0
  8. package/src/Button/Button.spec.ts +18 -0
  9. package/src/Button/Button.test.ts +36 -0
  10. package/src/Button/Button.vue +81 -0
  11. package/src/Expander/Expander.md +23 -0
  12. package/src/Expander/Expander.spec.ts +14 -0
  13. package/src/Expander/Expander.vue +95 -0
  14. package/src/Hint/Hint.md +24 -0
  15. package/src/Hint/Hint.spec.ts +12 -0
  16. package/src/Hint/Hint.test.ts +34 -0
  17. package/src/Hint/Hint.vue +83 -0
  18. package/src/Icon/Icon.md +55 -0
  19. package/src/Icon/Icon.spec.ts +9 -0
  20. package/src/Icon/Icon.vue +112 -0
  21. package/src/NumberInput/NumberInput.md +169 -0
  22. package/src/NumberInput/NumberInput.spec.ts +10 -0
  23. package/src/NumberInput/NumberInput.test.ts +328 -0
  24. package/src/NumberInput/NumberInput.vue +349 -0
  25. package/src/SelectBox/SelectBox.md +56 -0
  26. package/src/SelectBox/SelectBox.spec.ts +9 -0
  27. package/src/SelectBox/SelectBox.test.ts +42 -0
  28. package/src/SelectBox/SelectBox.vue +190 -0
  29. package/src/SidebarLayout/SidebarLayout.vue +270 -0
  30. package/src/Spinner/Spinner.md +45 -0
  31. package/src/Spinner/Spinner.spec.ts +9 -0
  32. package/src/Spinner/Spinner.vue +55 -0
  33. package/src/TextInput/TextInput.md +41 -0
  34. package/src/TextInput/TextInput.spec.ts +10 -0
  35. package/src/TextInput/TextInput.test.ts +70 -0
  36. package/src/TextInput/TextInput.vue +90 -0
  37. package/src/Toggle/Toggle.md +68 -0
  38. package/src/Toggle/Toggle.spec.ts +13 -0
  39. package/src/Toggle/Toggle.test.ts +35 -0
  40. package/src/Toggle/Toggle.vue +81 -0
  41. package/src/index.ts +13 -0
@@ -0,0 +1,56 @@
1
+ # SelectBox
2
+
3
+ A dropdown select built on reka-ui.
4
+
5
+ ## Examples
6
+
7
+ <script setup>
8
+ import { ref } from 'vue'
9
+ const interval = ref('weekly')
10
+ </script>
11
+
12
+ <ComponentDemo>
13
+ <div style="width: 200px">
14
+ <SelectBox
15
+ v-model="interval"
16
+ label="Interval"
17
+ :options="[
18
+ { value: 'daily', label: 'Daily' },
19
+ { value: 'weekly', label: 'Weekly' },
20
+ { value: 'monthly', label: 'Monthly' },
21
+ ]"
22
+ />
23
+ </div>
24
+
25
+ <template #code>
26
+
27
+ ```vue
28
+ <script setup>
29
+ import { ref } from "vue";
30
+ const interval = ref("weekly");
31
+ </script>
32
+
33
+ <SelectBox
34
+ v-model="interval"
35
+ label="Interval"
36
+ :options="[
37
+ { value: 'daily', label: 'Daily' },
38
+ { value: 'weekly', label: 'Weekly' },
39
+ { value: 'monthly', label: 'Monthly' },
40
+ ]"
41
+ />
42
+ ```
43
+
44
+ </template>
45
+ </ComponentDemo>
46
+
47
+ <!--@include: ./_api/select-box.md-->
48
+
49
+ ### SelectOption
50
+
51
+ ```ts
52
+ interface SelectOption {
53
+ value: string;
54
+ label: string;
55
+ }
56
+ ```
@@ -0,0 +1,9 @@
1
+ import { test, expect } from "@playwright/test";
2
+
3
+ test("SelectBox page renders demos", async ({ page }) => {
4
+ await page.goto("/cfa-simulator/cfasim-ui/components/select-box");
5
+ await expect(page.locator("h1")).toBeVisible();
6
+ const demos = page.locator(".demo-preview");
7
+ await expect(demos.first()).toBeVisible();
8
+ await expect(demos.first().getByText("Interval")).toBeVisible();
9
+ });
@@ -0,0 +1,42 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { mount } from "@vue/test-utils";
3
+ import SelectBox from "./SelectBox.vue";
4
+
5
+ const options = [
6
+ { value: "daily", label: "Daily" },
7
+ { value: "weekly", label: "Weekly" },
8
+ ];
9
+
10
+ describe("SelectBox", () => {
11
+ it("renders with label", () => {
12
+ const wrapper = mount(SelectBox, {
13
+ props: { label: "Interval", options, modelValue: "daily" },
14
+ });
15
+ expect(wrapper.text()).toContain("Interval");
16
+ });
17
+
18
+ it("renders without label", () => {
19
+ const wrapper = mount(SelectBox, {
20
+ props: { options, modelValue: "weekly" },
21
+ });
22
+ expect(wrapper.find(".select-label").exists()).toBe(false);
23
+ });
24
+
25
+ it("renders trigger element", () => {
26
+ const wrapper = mount(SelectBox, {
27
+ props: { options, modelValue: "weekly" },
28
+ });
29
+ const trigger = wrapper.find(".select-trigger");
30
+ expect(trigger.exists()).toBe(true);
31
+ expect(trigger.element.tagName).toBe("BUTTON");
32
+ });
33
+
34
+ it("has accessible trigger button", () => {
35
+ const wrapper = mount(SelectBox, {
36
+ props: { label: "Interval", options, modelValue: "daily" },
37
+ });
38
+ const trigger = wrapper.find(".select-trigger");
39
+ expect(trigger.attributes("role")).toBe("combobox");
40
+ expect(trigger.attributes("aria-labelledby")).toBeDefined();
41
+ });
42
+ });
@@ -0,0 +1,190 @@
1
+ <script setup lang="ts">
2
+ import {
3
+ SelectContent,
4
+ SelectItem,
5
+ SelectItemIndicator,
6
+ SelectItemText,
7
+ SelectPortal,
8
+ SelectRoot,
9
+ SelectTrigger,
10
+ SelectValue,
11
+ SelectViewport,
12
+ useId,
13
+ } from "reka-ui";
14
+
15
+ export interface SelectOption {
16
+ value: string;
17
+ label: string;
18
+ }
19
+
20
+ const model = defineModel<string>();
21
+
22
+ const props = defineProps<{
23
+ label?: string;
24
+ ariaLabel?: string;
25
+ options: SelectOption[];
26
+ placeholder?: string;
27
+ }>();
28
+
29
+ const id = useId();
30
+ </script>
31
+
32
+ <template>
33
+ <div class="select-box">
34
+ <label v-if="label" :id="`${id}-label`" class="select-label">{{
35
+ label
36
+ }}</label>
37
+ <SelectRoot v-model="model">
38
+ <SelectTrigger
39
+ class="select-trigger"
40
+ :aria-labelledby="props.label ? `${id}-label` : undefined"
41
+ :aria-label="!props.label ? props.ariaLabel : undefined"
42
+ >
43
+ <SelectValue :placeholder="placeholder" />
44
+ <span class="select-icon" aria-hidden="true">
45
+ <svg
46
+ width="12"
47
+ height="12"
48
+ viewBox="0 0 12 12"
49
+ fill="none"
50
+ stroke="currentColor"
51
+ stroke-width="2"
52
+ stroke-linecap="round"
53
+ stroke-linejoin="round"
54
+ >
55
+ <path d="M3 4.5L6 7.5L9 4.5" />
56
+ </svg>
57
+ </span>
58
+ </SelectTrigger>
59
+ <SelectPortal>
60
+ <SelectContent
61
+ class="select-content"
62
+ position="popper"
63
+ :side-offset="4"
64
+ :body-lock="false"
65
+ >
66
+ <SelectViewport class="select-viewport">
67
+ <SelectItem
68
+ v-for="opt in options"
69
+ :key="opt.value"
70
+ :value="opt.value"
71
+ class="select-item"
72
+ >
73
+ <SelectItemText>{{ opt.label }}</SelectItemText>
74
+ <SelectItemIndicator class="select-indicator">
75
+ <svg
76
+ width="12"
77
+ height="12"
78
+ viewBox="0 0 12 12"
79
+ fill="none"
80
+ stroke="currentColor"
81
+ stroke-width="2"
82
+ stroke-linecap="round"
83
+ stroke-linejoin="round"
84
+ >
85
+ <path d="M2 6L5 9L10 3" />
86
+ </svg>
87
+ </SelectItemIndicator>
88
+ </SelectItem>
89
+ </SelectViewport>
90
+ </SelectContent>
91
+ </SelectPortal>
92
+ </SelectRoot>
93
+ </div>
94
+ </template>
95
+
96
+ <style scoped>
97
+ .select-box {
98
+ display: flex;
99
+ flex-direction: column;
100
+ gap: 0.25em;
101
+ }
102
+
103
+ .select-label {
104
+ font-size: var(--font-size-sm);
105
+ }
106
+
107
+ .select-trigger {
108
+ display: inline-flex;
109
+ align-items: center;
110
+ justify-content: space-between;
111
+ gap: 0.5em;
112
+ font-size: var(--font-size-sm);
113
+ height: 2.5em;
114
+ padding: 0 0.75em;
115
+ border: 1px solid var(--color-border);
116
+ border-radius: 0.375em;
117
+ background: var(--color-bg-0);
118
+ cursor: pointer;
119
+ width: auto;
120
+ font-family: inherit;
121
+ color: inherit;
122
+ line-height: 1.4;
123
+ }
124
+
125
+ .select-trigger:hover {
126
+ border-color: var(--color-border-hover);
127
+ }
128
+
129
+ .select-trigger:focus-visible {
130
+ outline: 2px solid var(--color-primary);
131
+ outline-offset: -1px;
132
+ }
133
+
134
+ .select-trigger[data-placeholder] {
135
+ color: var(--color-text-secondary);
136
+ }
137
+
138
+ .select-icon {
139
+ display: flex;
140
+ align-items: center;
141
+ flex-shrink: 0;
142
+ }
143
+ </style>
144
+
145
+ <style>
146
+ .select-content {
147
+ z-index: 100;
148
+ background: var(--color-bg-0);
149
+ border: 1px solid var(--color-border);
150
+ border-radius: 0.25em;
151
+ box-shadow:
152
+ 0 4px 6px -1px rgba(0, 0, 0, 0.1),
153
+ 0 2px 4px -2px rgba(0, 0, 0, 0.1);
154
+ min-width: var(--reka-select-trigger-width);
155
+ max-height: var(--reka-select-content-available-height);
156
+ }
157
+
158
+ .select-viewport {
159
+ padding: 0.25em;
160
+ }
161
+
162
+ .select-item {
163
+ display: flex;
164
+ align-items: center;
165
+ justify-content: space-between;
166
+ gap: 0.5em;
167
+ padding: 0.25em 0.5em;
168
+ border-radius: 0.25em;
169
+ font-size: var(--font-size-sm);
170
+ white-space: nowrap;
171
+ cursor: pointer;
172
+ user-select: none;
173
+ outline: none;
174
+ }
175
+
176
+ .select-item[data-highlighted] {
177
+ background: var(--color-primary);
178
+ color: white;
179
+ }
180
+
181
+ .select-item[data-state="checked"] {
182
+ font-weight: 600;
183
+ }
184
+
185
+ .select-indicator {
186
+ display: flex;
187
+ align-items: center;
188
+ flex-shrink: 0;
189
+ }
190
+ </style>
@@ -0,0 +1,270 @@
1
+ <script setup lang="ts">
2
+ import { ref, onMounted, onUnmounted } from "vue";
3
+ import Icon from "../Icon/Icon.vue";
4
+
5
+ const mql = window.matchMedia("(max-width: 767px)");
6
+ const isMobile = ref(mql.matches);
7
+ const collapsed = ref(mql.matches);
8
+
9
+ function onMediaChange(e: MediaQueryListEvent) {
10
+ isMobile.value = e.matches;
11
+ collapsed.value = e.matches;
12
+ }
13
+
14
+ onMounted(() => {
15
+ mql.addEventListener("change", onMediaChange);
16
+ });
17
+
18
+ onUnmounted(() => {
19
+ mql.removeEventListener("change", onMediaChange);
20
+ });
21
+
22
+ function toggle() {
23
+ collapsed.value = !collapsed.value;
24
+ }
25
+ </script>
26
+
27
+ <template>
28
+ <div
29
+ class="SidebarLayout"
30
+ :data-collapsed="collapsed"
31
+ :data-mobile="isMobile"
32
+ >
33
+ <div v-if="isMobile && !collapsed" class="Overlay" @click="toggle" />
34
+ <button
35
+ v-if="isMobile"
36
+ type="button"
37
+ class="Toggle MobileToggle"
38
+ :aria-label="collapsed ? 'Expand sidebar' : 'Collapse sidebar'"
39
+ :title="collapsed ? 'Expand sidebar' : 'Collapse sidebar'"
40
+ @click="toggle"
41
+ >
42
+ <Icon
43
+ :icon="collapsed ? 'left_panel_open' : 'left_panel_close'"
44
+ size="sm"
45
+ />
46
+ </button>
47
+ <div class="SidebarRail">
48
+ <aside class="Sidebar">
49
+ <div class="SidebarScroll">
50
+ <slot name="sidebar" />
51
+ </div>
52
+ <button
53
+ v-if="!isMobile"
54
+ type="button"
55
+ class="Toggle"
56
+ :aria-label="collapsed ? 'Expand sidebar' : 'Collapse sidebar'"
57
+ :title="collapsed ? 'Expand sidebar' : 'Collapse sidebar'"
58
+ @click="toggle"
59
+ >
60
+ <Icon
61
+ :icon="collapsed ? 'left_panel_open' : 'left_panel_close'"
62
+ size="sm"
63
+ />
64
+ </button>
65
+ </aside>
66
+ </div>
67
+ <main class="Main">
68
+ <div class="MainScroll">
69
+ <div class="MainContent">
70
+ <slot />
71
+ </div>
72
+ </div>
73
+ </main>
74
+ </div>
75
+ </template>
76
+
77
+ <style scoped>
78
+ .SidebarLayout {
79
+ display: flex;
80
+ height: 100vh;
81
+ height: 100dvh;
82
+ background-color: var(--color-bg-0);
83
+ color: var(--color-text);
84
+ font-family: var(--font-family);
85
+ position: relative;
86
+ }
87
+
88
+ .SidebarRail {
89
+ flex-shrink: 0;
90
+ width: var(--sidebar-width);
91
+ height: 100%;
92
+ overflow: hidden;
93
+ transition: width var(--transition-normal);
94
+ }
95
+
96
+ .SidebarLayout[data-collapsed="true"] .SidebarRail {
97
+ width: var(--toggle-size);
98
+ background-color: var(--color-bg-1);
99
+ border-right: 1px solid var(--color-border);
100
+ box-shadow: var(--shadow-sm);
101
+ }
102
+
103
+ .Sidebar {
104
+ font-size: var(--font-size-sm);
105
+ display: flex;
106
+ width: var(--sidebar-width);
107
+ height: 100%;
108
+ transform: translateX(0);
109
+ transition: transform var(--transition-normal);
110
+ }
111
+
112
+ .SidebarLayout[data-collapsed="true"] .Sidebar {
113
+ transform: translateX(-100%);
114
+ }
115
+
116
+ .SidebarScroll {
117
+ flex: 1;
118
+ min-width: 0;
119
+ overflow-y: auto;
120
+ padding: var(--space-4);
121
+ background-color: var(--color-bg-1);
122
+ border-right: 1px solid var(--color-border);
123
+ box-shadow: var(--shadow-sm);
124
+ scrollbar-width: thin;
125
+ scrollbar-color: transparent transparent;
126
+ display: flex;
127
+ flex-direction: column;
128
+ gap: var(--space-3);
129
+ }
130
+
131
+ .SidebarScroll:hover {
132
+ scrollbar-color: var(--color-border) transparent;
133
+ }
134
+
135
+ .SidebarScroll :deep(h2) {
136
+ font-size: var(--font-size-sm);
137
+ font-weight: 600;
138
+ text-transform: uppercase;
139
+ letter-spacing: 0.05em;
140
+ color: var(--color-text-secondary);
141
+ margin: var(--space-4) 0 var(--space-2);
142
+ }
143
+
144
+ .SidebarScroll > :deep(h2:first-child) {
145
+ margin-top: 0;
146
+ }
147
+
148
+ .Toggle {
149
+ flex-shrink: 0;
150
+ display: flex;
151
+ align-items: center;
152
+ justify-content: center;
153
+ width: var(--toggle-size);
154
+ height: var(--toggle-size);
155
+ margin: 0;
156
+ padding: 0;
157
+ border: none;
158
+ background: transparent;
159
+ color: var(--color-text-secondary);
160
+ cursor: pointer;
161
+ transition:
162
+ color var(--transition-fast),
163
+ background-color var(--transition-fast);
164
+ }
165
+
166
+ .Toggle:hover {
167
+ color: var(--color-text);
168
+ background-color: var(--color-bg-2);
169
+ }
170
+
171
+ .Toggle:focus-visible {
172
+ outline: none;
173
+ box-shadow: var(--shadow-focus);
174
+ }
175
+
176
+ .SidebarLayout[data-collapsed="true"] .Toggle:not(.MobileToggle) {
177
+ transform: translateX(100%);
178
+ }
179
+
180
+ .Main {
181
+ flex: 1;
182
+ min-width: 0;
183
+ display: flex;
184
+ flex-direction: column;
185
+ overflow: hidden;
186
+ background-color: var(--color-bg-0);
187
+ font-size: var(--font-size-md);
188
+ }
189
+
190
+ .MainScroll {
191
+ flex: 1;
192
+ min-height: 0;
193
+ overflow-y: auto;
194
+ overflow-x: hidden;
195
+ padding: var(--space-6) 0;
196
+ scrollbar-width: thin;
197
+ scrollbar-color: transparent transparent;
198
+ }
199
+
200
+ .MainScroll:hover {
201
+ scrollbar-color: var(--color-border) transparent;
202
+ }
203
+
204
+ .MainContent {
205
+ max-width: 1024px;
206
+ padding: 0 var(--space-4);
207
+ }
208
+
209
+ .SidebarLayout[data-mobile="true"] .MainScroll {
210
+ padding-top: calc(var(--toggle-size) + var(--space-2));
211
+ }
212
+
213
+ @media (min-width: 768px) {
214
+ .MainContent {
215
+ padding: 0 var(--space-20);
216
+ }
217
+ }
218
+
219
+ /* Mobile: sidebar overlays content */
220
+ .Overlay {
221
+ position: fixed;
222
+ inset: 0;
223
+ background: rgba(0, 0, 0, 0.4);
224
+ z-index: 10;
225
+ }
226
+
227
+ .SidebarLayout[data-mobile="true"] .SidebarRail {
228
+ position: fixed;
229
+ top: 0;
230
+ left: 0;
231
+ z-index: 11;
232
+ width: var(--sidebar-width);
233
+ max-width: 85vw;
234
+ transition: transform var(--transition-normal);
235
+ transform: translateX(0);
236
+ }
237
+
238
+ .SidebarLayout[data-mobile="true"][data-collapsed="true"] .SidebarRail {
239
+ transform: translateX(-100%);
240
+ width: var(--sidebar-width);
241
+ max-width: 85vw;
242
+ background-color: transparent;
243
+ border-right: none;
244
+ box-shadow: none;
245
+ }
246
+
247
+ .SidebarLayout[data-mobile="true"] .Sidebar {
248
+ width: 100%;
249
+ }
250
+
251
+ .SidebarLayout[data-mobile="true"][data-collapsed="true"] .Sidebar {
252
+ transform: translateX(0);
253
+ }
254
+
255
+ .MobileToggle {
256
+ position: fixed;
257
+ top: var(--space-1);
258
+ left: calc(min(var(--sidebar-width), 85vw) + var(--space-1));
259
+ z-index: 12;
260
+ background: none;
261
+ border: none;
262
+ box-shadow: none;
263
+ border-radius: var(--radius-md);
264
+ transition: left var(--transition-normal);
265
+ }
266
+
267
+ .SidebarLayout[data-collapsed="true"] .MobileToggle {
268
+ left: var(--space-1);
269
+ }
270
+ </style>
@@ -0,0 +1,45 @@
1
+ # Spinner
2
+
3
+ A loading indicator with accessible labeling.
4
+
5
+ ## Examples
6
+
7
+ ### Sizes
8
+
9
+ <ComponentDemo>
10
+ <Spinner size="sm" label="Loading" />
11
+ <Spinner size="md" label="Loading" />
12
+ <Spinner size="lg" label="Loading" />
13
+
14
+ <template #code>
15
+
16
+ ```vue
17
+ <Spinner size="sm" label="Loading" />
18
+ <Spinner size="md" label="Loading" />
19
+ <Spinner size="lg" label="Loading" />
20
+ ```
21
+
22
+ </template>
23
+ </ComponentDemo>
24
+
25
+ ### In context
26
+
27
+ <ComponentDemo>
28
+ <div style="display: flex; align-items: center; gap: 8px;">
29
+ <Spinner size="sm" label="Running model" />
30
+ <span>Running model...</span>
31
+ </div>
32
+
33
+ <template #code>
34
+
35
+ ```vue
36
+ <div style="display: flex; align-items: center; gap: 8px;">
37
+ <Spinner size="sm" label="Running model" />
38
+ <span>Running model...</span>
39
+ </div>
40
+ ```
41
+
42
+ </template>
43
+ </ComponentDemo>
44
+
45
+ <!--@include: ./_api/spinner.md-->
@@ -0,0 +1,9 @@
1
+ import { test, expect } from "@playwright/test";
2
+
3
+ test("Spinner page renders demos", async ({ page }) => {
4
+ await page.goto("/cfa-simulator/cfasim-ui/components/spinner");
5
+ await expect(page.locator("h1")).toBeVisible();
6
+ const demos = page.locator(".demo-preview");
7
+ await expect(demos.first()).toBeVisible();
8
+ await expect(demos.first().locator('[role="status"]')).toHaveCount(3);
9
+ });
@@ -0,0 +1,55 @@
1
+ <script setup lang="ts">
2
+ export type SpinnerSize = "sm" | "md" | "lg";
3
+
4
+ withDefaults(
5
+ defineProps<{
6
+ size?: SpinnerSize;
7
+ label?: string;
8
+ }>(),
9
+ {
10
+ size: "sm",
11
+ },
12
+ );
13
+ </script>
14
+
15
+ <template>
16
+ <span
17
+ class="Spinner"
18
+ :data-size="size"
19
+ role="status"
20
+ :aria-label="label ?? 'Loading'"
21
+ />
22
+ </template>
23
+
24
+ <style>
25
+ .Spinner {
26
+ display: inline-block;
27
+ border: 2px solid currentColor;
28
+ border-top-color: transparent;
29
+ border-radius: 50%;
30
+ animation: cfa-spin 0.6s linear infinite;
31
+ vertical-align: middle;
32
+ }
33
+
34
+ .Spinner[data-size="sm"] {
35
+ width: 14px;
36
+ height: 14px;
37
+ }
38
+
39
+ .Spinner[data-size="md"] {
40
+ width: 20px;
41
+ height: 20px;
42
+ }
43
+
44
+ .Spinner[data-size="lg"] {
45
+ width: 28px;
46
+ height: 28px;
47
+ border-width: 3px;
48
+ }
49
+
50
+ @keyframes cfa-spin {
51
+ to {
52
+ transform: rotate(360deg);
53
+ }
54
+ }
55
+ </style>