@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,95 @@
1
+ <script setup lang="ts">
2
+ import {
3
+ CollapsibleRoot,
4
+ CollapsibleTrigger,
5
+ CollapsibleContent,
6
+ } from "reka-ui";
7
+
8
+ const open = defineModel<boolean>("open", { default: false });
9
+
10
+ defineProps<{
11
+ label: string;
12
+ }>();
13
+ </script>
14
+
15
+ <template>
16
+ <CollapsibleRoot v-model:open="open" class="expander">
17
+ <CollapsibleTrigger class="expander-trigger">
18
+ <span class="expander-caret" :class="{ open }" />
19
+ {{ label }}
20
+ </CollapsibleTrigger>
21
+ <CollapsibleContent class="expander-content">
22
+ <slot />
23
+ </CollapsibleContent>
24
+ </CollapsibleRoot>
25
+ </template>
26
+
27
+ <style scoped>
28
+ .expander-trigger {
29
+ display: flex;
30
+ align-items: center;
31
+ gap: 0.5em;
32
+ width: 100%;
33
+ padding: 0.5em 0;
34
+ background: none;
35
+ border: none;
36
+ cursor: pointer;
37
+ font: inherit;
38
+ font-size: var(--font-size-sm);
39
+ font-weight: 600;
40
+ text-transform: uppercase;
41
+ letter-spacing: 0.05em;
42
+ color: var(--color-text-secondary);
43
+ }
44
+
45
+ .expander-trigger:hover {
46
+ color: var(--color-text);
47
+ }
48
+
49
+ .expander-caret {
50
+ display: inline-block;
51
+ width: 0;
52
+ height: 0;
53
+ border-left: 0.35em solid currentColor;
54
+ border-top: 0.3em solid transparent;
55
+ border-bottom: 0.3em solid transparent;
56
+ transition: transform 0.15s;
57
+ }
58
+
59
+ .expander-caret.open {
60
+ transform: rotate(90deg);
61
+ }
62
+
63
+ .expander-content {
64
+ overflow: hidden;
65
+ display: flex;
66
+ flex-direction: column;
67
+ gap: 0.75em;
68
+ }
69
+
70
+ .expander-content[data-state="open"] {
71
+ animation: slideDown 200ms ease-out;
72
+ }
73
+
74
+ .expander-content[data-state="closed"] {
75
+ animation: slideUp 200ms ease-out;
76
+ }
77
+
78
+ @keyframes slideDown {
79
+ from {
80
+ height: 0;
81
+ }
82
+ to {
83
+ height: var(--reka-collapsible-content-height);
84
+ }
85
+ }
86
+
87
+ @keyframes slideUp {
88
+ from {
89
+ height: var(--reka-collapsible-content-height);
90
+ }
91
+ to {
92
+ height: 0;
93
+ }
94
+ }
95
+ </style>
@@ -0,0 +1,24 @@
1
+ # Hint
2
+
3
+ An info icon that shows a tooltip on hover. Used alongside form labels to provide additional context.
4
+
5
+ ## Examples
6
+
7
+ <ComponentDemo>
8
+ <span style="display: flex; align-items: center; gap: 8px;">
9
+ Population size <Hint text="The total number of individuals in the simulation." />
10
+ </span>
11
+
12
+ <template #code>
13
+
14
+ ```vue
15
+ <span style="display: flex; align-items: center; gap: 8px;">
16
+ Population size
17
+ <Hint text="The total number of individuals in the simulation." />
18
+ </span>
19
+ ```
20
+
21
+ </template>
22
+ </ComponentDemo>
23
+
24
+ <!--@include: ./_api/hint.md-->
@@ -0,0 +1,12 @@
1
+ import { test, expect } from "@playwright/test";
2
+
3
+ test("Hint page renders demos", async ({ page }) => {
4
+ await page.goto("/cfa-simulator/cfasim-ui/components/hint");
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("Population size")).toBeVisible();
9
+ await expect(
10
+ demos.first().getByRole("button", { name: "More info" }),
11
+ ).toBeVisible();
12
+ });
@@ -0,0 +1,34 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { mount } from "@vue/test-utils";
3
+ import Hint from "./Hint.vue";
4
+
5
+ describe("Hint", () => {
6
+ it("renders a trigger button with help icon", () => {
7
+ const wrapper = mount(Hint, {
8
+ props: { text: "Some hint text" },
9
+ });
10
+
11
+ const button = wrapper.find(".HintTrigger");
12
+ expect(button.exists()).toBe(true);
13
+ expect(button.attributes("aria-label")).toBe("More info");
14
+ expect(button.attributes("type")).toBe("button");
15
+ });
16
+
17
+ it("renders the help icon inside the trigger", () => {
18
+ const wrapper = mount(Hint, {
19
+ props: { text: "Some hint text" },
20
+ });
21
+
22
+ const icon = wrapper.find(".HintTrigger .Icon");
23
+ expect(icon.exists()).toBe(true);
24
+ expect(icon.text()).toBe("help");
25
+ });
26
+
27
+ it("does not show tooltip content by default", () => {
28
+ const wrapper = mount(Hint, {
29
+ props: { text: "Hidden until hover" },
30
+ });
31
+
32
+ expect(wrapper.text()).not.toContain("Hidden until hover");
33
+ });
34
+ });
@@ -0,0 +1,83 @@
1
+ <script setup lang="ts">
2
+ import {
3
+ TooltipArrow,
4
+ TooltipContent,
5
+ TooltipPortal,
6
+ TooltipProvider,
7
+ TooltipRoot,
8
+ TooltipTrigger,
9
+ } from "reka-ui";
10
+ import Icon from "../Icon/Icon.vue";
11
+
12
+ defineProps<{
13
+ text: string;
14
+ }>();
15
+ </script>
16
+
17
+ <template>
18
+ <TooltipProvider>
19
+ <TooltipRoot :delay-duration="0" disable-closing-trigger>
20
+ <TooltipTrigger as-child>
21
+ <button
22
+ type="button"
23
+ class="HintTrigger"
24
+ aria-label="More info"
25
+ @pointerdown.prevent
26
+ >
27
+ <Icon icon="help" :size="16" />
28
+ </button>
29
+ </TooltipTrigger>
30
+ <TooltipPortal>
31
+ <TooltipContent class="HintContent" side="top" :side-offset="4">
32
+ {{ text }}
33
+ <TooltipArrow class="HintArrow" :width="10" :height="5" />
34
+ </TooltipContent>
35
+ </TooltipPortal>
36
+ </TooltipRoot>
37
+ </TooltipProvider>
38
+ </template>
39
+
40
+ <style scoped>
41
+ .HintTrigger {
42
+ display: inline-flex;
43
+ align-items: center;
44
+ justify-content: center;
45
+ width: 1.25em;
46
+ height: 1.25em;
47
+ padding: 0;
48
+ margin: 0;
49
+ border: none;
50
+ border-radius: 50%;
51
+ background: transparent;
52
+ color: var(--color-text-secondary);
53
+ cursor: pointer;
54
+ flex-shrink: 0;
55
+ }
56
+
57
+ .HintTrigger:hover {
58
+ color: var(--color-text);
59
+ }
60
+
61
+ .HintTrigger:focus-visible {
62
+ outline: none;
63
+ box-shadow: var(--shadow-focus);
64
+ }
65
+ </style>
66
+
67
+ <style>
68
+ .HintContent {
69
+ max-width: 15rem;
70
+ padding: 0.5em 0.75em;
71
+ font-size: var(--font-size-xs);
72
+ line-height: 1.4;
73
+ color: var(--color-bg-0);
74
+ background-color: var(--color-text);
75
+ border-radius: 0.25em;
76
+ box-shadow: var(--shadow-md);
77
+ z-index: 100;
78
+ }
79
+
80
+ .HintArrow {
81
+ fill: var(--color-text);
82
+ }
83
+ </style>
@@ -0,0 +1,55 @@
1
+ # Icon
2
+
3
+ Renders a [Material Symbols Outlined](https://fonts.google.com/icons) icon.
4
+
5
+ ## Examples
6
+
7
+ ### Sizes
8
+
9
+ <ComponentDemo>
10
+ <Icon icon="help" size="sm" aria-label="help" />
11
+ <Icon icon="help" size="md" aria-label="help" />
12
+ <Icon icon="help" size="lg" aria-label="help" />
13
+
14
+ <template #code>
15
+
16
+ ```vue
17
+ <Icon icon="help" size="sm" aria-label="help" />
18
+ <Icon icon="help" size="md" aria-label="help" />
19
+ <Icon icon="help" size="lg" aria-label="help" />
20
+ ```
21
+
22
+ </template>
23
+ </ComponentDemo>
24
+
25
+ ### Filled
26
+
27
+ <ComponentDemo>
28
+ <Icon icon="favorite" size="lg" aria-label="favorite" />
29
+ <Icon icon="favorite" size="lg" :fill="true" aria-label="favorite filled" />
30
+
31
+ <template #code>
32
+
33
+ ```vue
34
+ <Icon icon="favorite" size="lg" aria-label="favorite" />
35
+ <Icon icon="favorite" size="lg" :fill="true" aria-label="favorite filled" />
36
+ ```
37
+
38
+ </template>
39
+ </ComponentDemo>
40
+
41
+ ### Inline in text
42
+
43
+ <ComponentDemo>
44
+ <p style="margin: 0">Click the <Icon icon="help" size="sm" :inline="true" aria-label="help" /> icon for more info.</p>
45
+
46
+ <template #code>
47
+
48
+ ```vue
49
+ <p>Click the <Icon icon="help" size="sm" :inline="true" aria-label="help" /> icon for more info.</p>
50
+ ```
51
+
52
+ </template>
53
+ </ComponentDemo>
54
+
55
+ <!--@include: ./_api/icon.md-->
@@ -0,0 +1,9 @@
1
+ import { test, expect } from "@playwright/test";
2
+
3
+ test("Icon page renders demos", async ({ page }) => {
4
+ await page.goto("/cfa-simulator/cfasim-ui/components/icon");
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(".Icon")).toHaveCount(3);
9
+ });
@@ -0,0 +1,112 @@
1
+ <script setup lang="ts">
2
+ import type { CSSProperties } from "vue";
3
+ import { computed } from "vue";
4
+
5
+ export type IconSize = "sm" | "md" | "lg" | "xl";
6
+
7
+ interface Props {
8
+ icon: string;
9
+ size?: IconSize | number;
10
+ fill?: boolean;
11
+ weight?: number;
12
+ grade?: number;
13
+ decorative?: boolean;
14
+ ariaLabel?: string;
15
+ inline?: boolean;
16
+ }
17
+
18
+ const props = withDefaults(defineProps<Props>(), {
19
+ size: "md",
20
+ fill: false,
21
+ decorative: true,
22
+ inline: false,
23
+ });
24
+
25
+ const sizePreset = computed(() =>
26
+ typeof props.size === "string" ? props.size : undefined,
27
+ );
28
+ const numericSize = computed(() =>
29
+ typeof props.size === "number" ? props.size : undefined,
30
+ );
31
+
32
+ const inlineStyle = computed<CSSProperties>(() => {
33
+ const style: CSSProperties = {};
34
+ if (numericSize.value !== undefined) {
35
+ style.fontSize = `${numericSize.value}px`;
36
+ (style as Record<string, unknown>)["--icon-opsz"] = numericSize.value;
37
+ }
38
+ if (props.weight !== undefined) {
39
+ (style as Record<string, unknown>)["--icon-weight"] = props.weight;
40
+ }
41
+ if (props.grade !== undefined) {
42
+ (style as Record<string, unknown>)["--icon-grade"] = props.grade;
43
+ }
44
+ return style;
45
+ });
46
+ </script>
47
+
48
+ <template>
49
+ <span
50
+ class="Icon"
51
+ :data-size="sizePreset"
52
+ :data-fill="fill ? 'true' : undefined"
53
+ :data-inline="inline ? 'true' : undefined"
54
+ :style="inlineStyle"
55
+ :aria-hidden="decorative ? true : undefined"
56
+ :aria-label="decorative ? undefined : ariaLabel"
57
+ :role="decorative ? undefined : 'img'"
58
+ >{{ icon }}</span
59
+ >
60
+ </template>
61
+
62
+ <style>
63
+ .Icon {
64
+ font-family: "Material Symbols Outlined", sans-serif;
65
+ font-weight: normal;
66
+ font-style: normal;
67
+ font-size: 24px;
68
+ line-height: 1;
69
+ letter-spacing: normal;
70
+ text-transform: none;
71
+ display: inline-block;
72
+ white-space: nowrap;
73
+ word-wrap: normal;
74
+ direction: ltr;
75
+ font-feature-settings: "liga";
76
+ -webkit-font-smoothing: antialiased;
77
+ font-variation-settings:
78
+ "FILL" var(--icon-fill, 0),
79
+ "wght" var(--icon-weight, 400),
80
+ "GRAD" var(--icon-grade, 0),
81
+ "opsz" var(--icon-opsz, 24);
82
+ color: inherit;
83
+ }
84
+
85
+ .Icon[data-size="sm"] {
86
+ font-size: 20px;
87
+ --icon-opsz: 20;
88
+ }
89
+ .Icon[data-size="md"] {
90
+ font-size: 24px;
91
+ --icon-opsz: 24;
92
+ }
93
+ .Icon[data-size="lg"] {
94
+ font-size: 28px;
95
+ --icon-opsz: 28;
96
+ }
97
+ .Icon[data-size="xl"] {
98
+ font-size: 32px;
99
+ --icon-opsz: 32;
100
+ }
101
+
102
+ .Icon[data-fill="true"] {
103
+ --icon-fill: 1;
104
+ }
105
+
106
+ .Icon[data-inline="true"] {
107
+ font-size: inherit;
108
+ vertical-align: middle;
109
+ transform: scale(1.2) translateY(-0.05em);
110
+ transform-origin: 50% 50%;
111
+ }
112
+ </style>
@@ -0,0 +1,169 @@
1
+ # NumberInput
2
+
3
+ A number input field with optional slider, percent mode, and validation.
4
+
5
+ ## Examples
6
+
7
+ ### Basic
8
+
9
+ <script setup>
10
+ import { ref } from 'vue'
11
+ const days = ref(10)
12
+ const population = ref(100000)
13
+ const coverage = ref(0.5)
14
+ const r0 = ref(3.5)
15
+ </script>
16
+
17
+ <ComponentDemo>
18
+ <div style="width: 300px">
19
+ <NumberInput v-model="days" label="Days" placeholder="Number of days" />
20
+ </div>
21
+
22
+ <template #code>
23
+
24
+ ```vue
25
+ <script setup>
26
+ import { ref } from "vue";
27
+ const days = ref(10);
28
+ </script>
29
+
30
+ <NumberInput v-model="days" label="Days" placeholder="Number of days" />
31
+ ```
32
+
33
+ </template>
34
+ </ComponentDemo>
35
+
36
+ ### With hint and validation
37
+
38
+ <ComponentDemo>
39
+ <div style="width: 300px">
40
+ <NumberInput
41
+ v-model="population"
42
+ label="Population"
43
+ hint="Total number of individuals"
44
+ :min="1000"
45
+ :max="100000"
46
+ :step="1"
47
+ />
48
+ </div>
49
+
50
+ <template #code>
51
+
52
+ ```vue
53
+ <NumberInput
54
+ v-model="population"
55
+ label="Population"
56
+ hint="Total number of individuals"
57
+ :min="1000"
58
+ :max="100000"
59
+ :step="1"
60
+ />
61
+ ```
62
+
63
+ </template>
64
+ </ComponentDemo>
65
+
66
+ ### Percent mode
67
+
68
+ <ComponentDemo>
69
+ <div style="width: 300px">
70
+ <NumberInput
71
+ v-model="coverage"
72
+ label="Vaccination coverage"
73
+ percent
74
+ :max="1"
75
+ />
76
+ </div>
77
+
78
+ <template #code>
79
+
80
+ ```vue
81
+ <NumberInput v-model="coverage" label="Vaccination coverage" percent :max="1" />
82
+ ```
83
+
84
+ </template>
85
+ </ComponentDemo>
86
+
87
+ ### Slider
88
+
89
+ <ComponentDemo>
90
+ <div style="width: 300px">
91
+ <NumberInput
92
+ v-model="r0"
93
+ label="R0"
94
+ hint="Basic reproduction number"
95
+ :step="0.1"
96
+ :min="1"
97
+ :max="18"
98
+ slider
99
+ />
100
+ </div>
101
+
102
+ <template #code>
103
+
104
+ ```vue
105
+ <NumberInput
106
+ v-model="r0"
107
+ label="R0"
108
+ hint="Basic reproduction number"
109
+ :step="0.1"
110
+ :min="1"
111
+ :max="18"
112
+ slider
113
+ />
114
+ ```
115
+
116
+ </template>
117
+ </ComponentDemo>
118
+
119
+ ### Live slider
120
+
121
+ With `live`, the model updates while dragging the slider thumb rather than only on release.
122
+
123
+ <ComponentDemo>
124
+ <div style="width: 300px">
125
+ <NumberInput
126
+ v-model="coverage"
127
+ label="Vaccination coverage"
128
+ percent
129
+ slider
130
+ live
131
+ :max="1"
132
+ />
133
+ </div>
134
+
135
+ <template #code>
136
+
137
+ ```vue
138
+ <NumberInput
139
+ v-model="coverage"
140
+ label="Vaccination coverage"
141
+ percent
142
+ slider
143
+ live
144
+ :max="1"
145
+ />
146
+ ```
147
+
148
+ </template>
149
+ </ComponentDemo>
150
+
151
+ ### Live input
152
+
153
+ With `live` on a regular input, the model updates as you type (debounced 300ms). Arrow keys and spinner buttons commit immediately.
154
+
155
+ <ComponentDemo>
156
+ <div style="width: 300px">
157
+ <NumberInput v-model="days" label="Days" live />
158
+ </div>
159
+
160
+ <template #code>
161
+
162
+ ```vue
163
+ <NumberInput v-model="days" label="Days" live />
164
+ ```
165
+
166
+ </template>
167
+ </ComponentDemo>
168
+
169
+ <!--@include: ./_api/number-input.md-->
@@ -0,0 +1,10 @@
1
+ import { test, expect } from "@playwright/test";
2
+
3
+ test("NumberInput page renders demos", async ({ page }) => {
4
+ await page.goto("/cfa-simulator/cfasim-ui/components/number-input");
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("Days")).toBeVisible();
9
+ await expect(demos.first().locator('input[type="number"]')).toBeVisible();
10
+ });