@cfasim-ui/docs 0.3.15 → 0.3.17

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.
@@ -0,0 +1,103 @@
1
+ # Container
2
+
3
+ A flexible wrapper for grouping elements vertically (default) or horizontally. Optionally adds a border, fixed height with scrolling, and configurable gap between children.
4
+
5
+ For multi-column layouts use [Grid](./grid).
6
+
7
+ ## Examples
8
+
9
+ ### Vertical stack (default)
10
+
11
+ <ComponentDemo>
12
+ <Container gap="small">
13
+ <Box variant="info">First</Box>
14
+ <Box variant="info">Second</Box>
15
+ <Box variant="info">Third</Box>
16
+ </Container>
17
+
18
+ <template #code>
19
+
20
+ ```vue
21
+ <Container gap="small">
22
+ <Box variant="info">First</Box>
23
+ <Box variant="info">Second</Box>
24
+ <Box variant="info">Third</Box>
25
+ </Container>
26
+ ```
27
+
28
+ </template>
29
+ </ComponentDemo>
30
+
31
+ ### Horizontal row
32
+
33
+ A row of buttons or chips. Wraps onto multiple lines when space runs out.
34
+
35
+ <ComponentDemo>
36
+ <Container horizontal gap="small">
37
+ <Button>Save</Button>
38
+ <Button variant="secondary">Cancel</Button>
39
+ <Button variant="secondary">Reset</Button>
40
+ </Container>
41
+
42
+ <template #code>
43
+
44
+ ```vue
45
+ <Container horizontal gap="small">
46
+ <Button>Save</Button>
47
+ <Button variant="secondary">Cancel</Button>
48
+ <Button variant="secondary">Reset</Button>
49
+ </Container>
50
+ ```
51
+
52
+ </template>
53
+ </ComponentDemo>
54
+
55
+ ### Card with border
56
+
57
+ <ComponentDemo>
58
+ <Container border gap="small">
59
+ <strong>Run summary</strong>
60
+ <span>Generated 1,000 samples in 2.4s</span>
61
+ </Container>
62
+
63
+ <template #code>
64
+
65
+ ```vue
66
+ <Container border gap="small">
67
+ <strong>Run summary</strong>
68
+ <span>Generated 1,000 samples in 2.4s</span>
69
+ </Container>
70
+ ```
71
+
72
+ </template>
73
+ </ComponentDemo>
74
+
75
+ ### Scrollable region
76
+
77
+ Setting `height` automatically enables scrolling when content overflows.
78
+
79
+ <ComponentDemo>
80
+ <Container border :height="180" gap="small">
81
+ <div v-for="i in 30" :key="i">Line {{ i }}</div>
82
+ </Container>
83
+
84
+ <template #code>
85
+
86
+ ```vue
87
+ <Container border :height="180" gap="small">
88
+ <div v-for="i in 30" :key="i">Line {{ i }}</div>
89
+ </Container>
90
+ ```
91
+
92
+ </template>
93
+ </ComponentDemo>
94
+
95
+ ## Props
96
+
97
+ | Prop | Type | Required | Default |
98
+ |------|------|----------|---------|
99
+ | `border` | `boolean` | No | — |
100
+ | `height` | `number \| string` | No | — |
101
+ | `horizontal` | `boolean` | No | — |
102
+ | `gap` | `ContainerGap \| string` | No | — |
103
+
@@ -0,0 +1,62 @@
1
+ <script setup lang="ts">
2
+ import { computed } from "vue";
3
+ import { type GapToken, resolveGap } from "../_internal/gap";
4
+
5
+ export type ContainerGap = GapToken;
6
+
7
+ const props = defineProps<{
8
+ border?: boolean;
9
+ height?: number | string;
10
+ horizontal?: boolean;
11
+ gap?: ContainerGap | string;
12
+ }>();
13
+
14
+ const resolvedGap = computed(() => resolveGap(props.gap));
15
+
16
+ const resolvedHeight = computed(() => {
17
+ const h = props.height;
18
+ if (h == null) return undefined;
19
+ return typeof h === "number" ? `${h}px` : h;
20
+ });
21
+ </script>
22
+
23
+ <template>
24
+ <div
25
+ class="container"
26
+ :class="{
27
+ 'container-border': border,
28
+ 'container-horizontal': horizontal,
29
+ 'container-scrollable': height != null,
30
+ }"
31
+ :style="{
32
+ gap: resolvedGap,
33
+ height: resolvedHeight,
34
+ }"
35
+ >
36
+ <slot />
37
+ </div>
38
+ </template>
39
+
40
+ <style scoped>
41
+ .container {
42
+ display: flex;
43
+ flex-direction: column;
44
+ min-width: 0;
45
+ }
46
+
47
+ .container-horizontal {
48
+ flex-direction: row;
49
+ flex-wrap: wrap;
50
+ align-items: center;
51
+ }
52
+
53
+ .container-border {
54
+ padding: var(--space-4);
55
+ border: 1px solid var(--color-border);
56
+ border-radius: var(--radius-md);
57
+ }
58
+
59
+ .container-scrollable {
60
+ overflow: auto;
61
+ }
62
+ </style>
@@ -0,0 +1,170 @@
1
+ # Grid
2
+
3
+ A CSS-grid wrapper for arranging elements in equal, proportional, or auto-fitting columns.
4
+
5
+ For single-direction stacks (vertical or a flex row that wraps), use [Container](./container) instead.
6
+
7
+ ## Examples
8
+
9
+ ### Equal columns
10
+
11
+ Pass a number for N equal-width columns.
12
+
13
+ <ComponentDemo>
14
+ <Grid :cols="3" gap="medium">
15
+ <Box variant="info">Column 1</Box>
16
+ <Box variant="info">Column 2</Box>
17
+ <Box variant="info">Column 3</Box>
18
+ </Grid>
19
+
20
+ <template #code>
21
+
22
+ ```vue
23
+ <Grid :cols="3" gap="medium">
24
+ <Box variant="info">Column 1</Box>
25
+ <Box variant="info">Column 2</Box>
26
+ <Box variant="info">Column 3</Box>
27
+ </Grid>
28
+ ```
29
+
30
+ </template>
31
+ </ComponentDemo>
32
+
33
+ ### Proportional widths
34
+
35
+ Pass an array of `fr` weights for asymmetric layouts.
36
+
37
+ <ComponentDemo>
38
+ <Grid :cols="[2, 3, 1]" gap="medium">
39
+ <Box variant="info">2fr</Box>
40
+ <Box variant="success">3fr</Box>
41
+ <Box variant="warning">1fr</Box>
42
+ </Grid>
43
+
44
+ <template #code>
45
+
46
+ ```vue
47
+ <Grid :cols="[2, 3, 1]" gap="medium">
48
+ <Box variant="info">2fr</Box>
49
+ <Box variant="success">3fr</Box>
50
+ <Box variant="warning">1fr</Box>
51
+ </Grid>
52
+ ```
53
+
54
+ </template>
55
+ </ComponentDemo>
56
+
57
+ ### Mixed track sizes
58
+
59
+ Strings pass through unchanged, so you can combine fixed and flexible tracks.
60
+
61
+ <ComponentDemo>
62
+ <Grid :cols="['200px', '1fr']" gap="medium">
63
+ <Box variant="info">Fixed 200px</Box>
64
+ <Box variant="info">Fills remaining space</Box>
65
+ </Grid>
66
+
67
+ <template #code>
68
+
69
+ ```vue
70
+ <Grid :cols="['200px', '1fr']" gap="medium">
71
+ <Box variant="info">Fixed 200px</Box>
72
+ <Box variant="info">Fills remaining space</Box>
73
+ </Grid>
74
+ ```
75
+
76
+ </template>
77
+ </ComponentDemo>
78
+
79
+ ### Small-width breakpoint
80
+
81
+ `colsSmall` overrides `cols` when the grid's own width is at or below `breakpoint`. The default breakpoint is `640px` unless you specify it. The check is a CSS container query against the grid itself, so it triggers based on the grid's available width (e.g. when nested inside a sidebar), not the viewport.
82
+
83
+ <ComponentDemo>
84
+ <Grid :cols="3" :cols-small="1" breakpoint="480px" gap="small">
85
+ <Box variant="info">Card 1</Box>
86
+ <Box variant="info">Card 2</Box>
87
+ <Box variant="info">Card 3</Box>
88
+ </Grid>
89
+
90
+ <template #code>
91
+
92
+ ```vue
93
+ <Grid :cols="3" :cols-small="1" breakpoint="480px" gap="small">
94
+ <Box variant="info">Card 1</Box>
95
+ <Box variant="info">Card 2</Box>
96
+ <Box variant="info">Card 3</Box>
97
+ </Grid>
98
+ ```
99
+
100
+ </template>
101
+ </ComponentDemo>
102
+
103
+ ### Responsive auto-fit
104
+
105
+ `minColWidth` switches to `repeat(auto-fit, minmax(...))` so items reflow to fit the viewport without media queries. Great for metric tiles and card grids.
106
+
107
+ <ComponentDemo>
108
+ <Grid min-col-width="180px" gap="medium">
109
+ <Box variant="info">Card 1</Box>
110
+ <Box variant="info">Card 2</Box>
111
+ <Box variant="info">Card 3</Box>
112
+ <Box variant="info">Card 4</Box>
113
+ <Box variant="info">Card 5</Box>
114
+ </Grid>
115
+
116
+ <template #code>
117
+
118
+ ```vue
119
+ <Grid min-col-width="180px" gap="medium">
120
+ <Box variant="info">Card 1</Box>
121
+ <Box variant="info">Card 2</Box>
122
+ <Box variant="info">Card 3</Box>
123
+ <Box variant="info">Card 4</Box>
124
+ <Box variant="info">Card 5</Box>
125
+ </Grid>
126
+ ```
127
+
128
+ </template>
129
+ </ComponentDemo>
130
+
131
+ ### Nested grids
132
+
133
+ <ComponentDemo>
134
+ <Grid :cols="2" gap="medium">
135
+ <Box variant="info">Left</Box>
136
+ <Grid :cols="2" gap="small">
137
+ <Box variant="success">a</Box>
138
+ <Box variant="success">b</Box>
139
+ <Box variant="success">c</Box>
140
+ <Box variant="success">d</Box>
141
+ </Grid>
142
+ </Grid>
143
+
144
+ <template #code>
145
+
146
+ ```vue
147
+ <Grid :cols="2" gap="medium">
148
+ <Box variant="info">Left</Box>
149
+ <Grid :cols="2" gap="small">
150
+ <Box variant="success">a</Box>
151
+ <Box variant="success">b</Box>
152
+ <Box variant="success">c</Box>
153
+ <Box variant="success">d</Box>
154
+ </Grid>
155
+ </Grid>
156
+ ```
157
+
158
+ </template>
159
+ </ComponentDemo>
160
+
161
+ ## Props
162
+
163
+ | Prop | Type | Required | Default |
164
+ |------|------|----------|---------|
165
+ | `cols` | `GridCols` | No | — |
166
+ | `colsSmall` | `GridCols` | No | — |
167
+ | `breakpoint` | `string` | No | — |
168
+ | `gap` | `GridGap \| string` | No | — |
169
+ | `minColWidth` | `string` | No | — |
170
+
@@ -0,0 +1,145 @@
1
+ <script setup lang="ts">
2
+ import { computed, onUnmounted, watch } from "vue";
3
+ import { type GapToken, resolveGap } from "../_internal/gap";
4
+
5
+ export type GridGap = GapToken;
6
+ export type GridCols = number | (number | string)[];
7
+
8
+ const props = defineProps<{
9
+ cols?: GridCols;
10
+ colsSmall?: GridCols;
11
+ breakpoint?: string;
12
+ gap?: GridGap | string;
13
+ minColWidth?: string;
14
+ }>();
15
+
16
+ const resolvedGap = computed(() => resolveGap(props.gap));
17
+
18
+ const resolvedColumns = computed(() => {
19
+ if (props.minColWidth) {
20
+ return `repeat(auto-fit, minmax(${props.minColWidth}, 1fr))`;
21
+ }
22
+ return colsToTemplate(props.cols ?? 2);
23
+ });
24
+
25
+ const resolvedSmallColumns = computed(() => {
26
+ if (props.minColWidth || props.colsSmall == null) {
27
+ return resolvedColumns.value;
28
+ }
29
+ return colsToTemplate(props.colsSmall);
30
+ });
31
+
32
+ const safeBreakpoint = computed(() =>
33
+ sanitizeBreakpoint(props.breakpoint ?? DEFAULT_BREAKPOINT),
34
+ );
35
+
36
+ let acquired: string | null = null;
37
+ const stopWatch = watch(
38
+ safeBreakpoint,
39
+ (next) => {
40
+ if (acquired === next) return;
41
+ acquireBreakpoint(next);
42
+ if (acquired) releaseBreakpoint(acquired);
43
+ acquired = next;
44
+ },
45
+ { immediate: true },
46
+ );
47
+
48
+ onUnmounted(() => {
49
+ stopWatch();
50
+ if (acquired) {
51
+ releaseBreakpoint(acquired);
52
+ acquired = null;
53
+ }
54
+ });
55
+ </script>
56
+
57
+ <script lang="ts">
58
+ const DEFAULT_BREAKPOINT = "640px";
59
+
60
+ function colsToTemplate(cols: GridCols): string {
61
+ if (typeof cols === "number") return `repeat(${cols}, 1fr)`;
62
+ return cols.map((c) => (typeof c === "number" ? `${c}fr` : c)).join(" ");
63
+ }
64
+
65
+ // Allow only digits, dot, and unit letters/percent. Falls back to the default
66
+ // when input doesn't look like a CSS length, preventing rule-escape via the
67
+ // breakpoint prop.
68
+ function sanitizeBreakpoint(value: string): string {
69
+ return /^\d+(\.\d+)?[a-zA-Z%]+$/.test(value) ? value : DEFAULT_BREAKPOINT;
70
+ }
71
+
72
+ // Module-scope cache: one <style> per unique breakpoint, ref-counted across
73
+ // all Grid instances. The grid's inline `--grid-cols-small` is consumed by
74
+ // the rule in the cached <style>.
75
+ type Entry = { count: number; el: HTMLStyleElement };
76
+ const breakpointSheets = new Map<string, Entry>();
77
+
78
+ function acquireBreakpoint(breakpoint: string) {
79
+ if (typeof document === "undefined") return;
80
+ const existing = breakpointSheets.get(breakpoint);
81
+ if (existing) {
82
+ existing.count++;
83
+ return;
84
+ }
85
+ const el = document.createElement("style");
86
+ el.setAttribute("data-cfasim-grid-bp", breakpoint);
87
+ el.textContent =
88
+ `@container (max-width: ${breakpoint}) {` +
89
+ `[data-cfasim-grid-bp="${breakpoint}"] > .grid {` +
90
+ `grid-template-columns: var(--grid-cols-small) !important;` +
91
+ `}}`;
92
+ document.head.appendChild(el);
93
+ breakpointSheets.set(breakpoint, { count: 1, el });
94
+ }
95
+
96
+ function releaseBreakpoint(breakpoint: string) {
97
+ if (typeof document === "undefined") return;
98
+ const entry = breakpointSheets.get(breakpoint);
99
+ if (!entry) return;
100
+ entry.count--;
101
+ if (entry.count === 0) {
102
+ entry.el.remove();
103
+ breakpointSheets.delete(breakpoint);
104
+ }
105
+ }
106
+
107
+ const hmr = (import.meta as { hot?: { dispose: (cb: () => void) => void } })
108
+ .hot;
109
+ hmr?.dispose(() => {
110
+ breakpointSheets.forEach(({ el }) => el.remove());
111
+ breakpointSheets.clear();
112
+ });
113
+ </script>
114
+
115
+ <template>
116
+ <div :data-cfasim-grid-bp="safeBreakpoint" class="grid-wrapper">
117
+ <div
118
+ class="grid"
119
+ :style="{
120
+ gap: resolvedGap,
121
+ gridTemplateColumns: resolvedColumns,
122
+ '--grid-cols-small': resolvedSmallColumns,
123
+ }"
124
+ >
125
+ <slot />
126
+ </div>
127
+ </div>
128
+ </template>
129
+
130
+ <style scoped>
131
+ .grid-wrapper {
132
+ container-type: inline-size;
133
+ width: 100%;
134
+ min-width: 0;
135
+ }
136
+
137
+ .grid {
138
+ display: grid;
139
+ min-width: 0;
140
+ }
141
+
142
+ .grid > :deep(*) {
143
+ min-width: 0;
144
+ }
145
+ </style>
@@ -12,6 +12,10 @@ const days = ref(10)
12
12
  const population = ref(100000)
13
13
  const coverage = ref(0.5)
14
14
  const r0 = ref(3.5)
15
+ const ageRange = ref([18, 65])
16
+ const coverageRange = ref([0.2, 0.8])
17
+ const minAge = ref(18)
18
+ const maxAge = ref(65)
15
19
  </script>
16
20
 
17
21
  <ComponentDemo>
@@ -116,6 +120,112 @@ const days = ref(10);
116
120
  </template>
117
121
  </ComponentDemo>
118
122
 
123
+ ### Range slider
124
+
125
+ Bind `v-model:range` with a `[low, high]` tuple to render a two-handle
126
+ slider. Range mode is enabled automatically by the binding — there's no
127
+ explicit toggle prop.
128
+
129
+ <ComponentDemo>
130
+ <div style="width: 300px">
131
+ <NumberInput
132
+ v-model:range="ageRange"
133
+ label="Age range"
134
+ :min="0"
135
+ :max="100"
136
+ number-type="integer"
137
+ />
138
+ </div>
139
+
140
+ <template #code>
141
+
142
+ ```vue
143
+ <script setup>
144
+ import { ref } from "vue";
145
+ const ageRange = ref([18, 65]);
146
+ </script>
147
+
148
+ <NumberInput
149
+ v-model:range="ageRange"
150
+ label="Age range"
151
+ :min="0"
152
+ :max="100"
153
+ number-type="integer"
154
+ />
155
+ ```
156
+
157
+ </template>
158
+ </ComponentDemo>
159
+
160
+ ### Range slider with split bindings
161
+
162
+ When your state stores the bounds in separate refs (rather than as a tuple),
163
+ bind them directly with `v-model:lower` and `v-model:upper`. You can bind
164
+ either pair or combine them with `v-model:range` — writes from the component
165
+ go to every bound sink.
166
+
167
+ <ComponentDemo>
168
+ <div style="width: 300px">
169
+ <NumberInput
170
+ v-model:lower="minAge"
171
+ v-model:upper="maxAge"
172
+ label="Age range (split)"
173
+ :min="0"
174
+ :max="100"
175
+ number-type="integer"
176
+ />
177
+ </div>
178
+
179
+ <template #code>
180
+
181
+ ```vue
182
+ <script setup>
183
+ import { ref } from "vue";
184
+ const minAge = ref(18);
185
+ const maxAge = ref(65);
186
+ </script>
187
+
188
+ <NumberInput
189
+ v-model:lower="minAge"
190
+ v-model:upper="maxAge"
191
+ label="Age range"
192
+ :min="0"
193
+ :max="100"
194
+ number-type="integer"
195
+ />
196
+ ```
197
+
198
+ </template>
199
+ </ComponentDemo>
200
+
201
+ Range mode works with `percent` and `live` as well:
202
+
203
+ <ComponentDemo>
204
+ <div style="width: 300px">
205
+ <NumberInput
206
+ v-model:range="coverageRange"
207
+ label="Coverage range"
208
+ percent
209
+ live
210
+ :max="1"
211
+ />
212
+ </div>
213
+
214
+ <template #code>
215
+
216
+ ```vue
217
+ <NumberInput
218
+ v-model:range="coverageRange"
219
+ label="Coverage range"
220
+ percent
221
+ live
222
+ :max="1"
223
+ />
224
+ ```
225
+
226
+ </template>
227
+ </ComponentDemo>
228
+
119
229
  ### Live slider
120
230
 
121
231
  With `live`, the model updates while dragging the slider thumb rather than only on release.
@@ -284,6 +394,9 @@ the input visually.
284
394
  | Name | Type |
285
395
  |------|------|
286
396
  | `v-model` | `number` |
397
+ | `v-model:range` | `NumberRange` |
398
+ | `v-model:lower` | `number` |
399
+ | `v-model:upper` | `number` |
287
400
 
288
401
  ## Props
289
402
 
@@ -1,9 +1,19 @@
1
1
  <script setup lang="ts">
2
- import { ref, watch, computed } from "vue";
2
+ import { ref, watch, computed, onMounted, getCurrentInstance } from "vue";
3
3
  import { SliderRoot, SliderTrack, SliderRange, SliderThumb } from "reka-ui";
4
4
  import Hint from "../Hint/Hint.vue";
5
5
 
6
+ export type NumberRange = [number, number];
7
+
8
+ // The default `v-model` is always a scalar number. For range mode, bind
9
+ // `v-model:range` (a tuple) and/or the split `v-model:lower`/`v-model:upper`.
10
+ // Mode is auto-detected from which v-model bindings the parent provides;
11
+ // no explicit toggle prop. Precedence on read: lower/upper > range >
12
+ // slider defaults.
6
13
  const model = defineModel<number>();
14
+ const range = defineModel<NumberRange>("range");
15
+ const lower = defineModel<number>("lower");
16
+ const upper = defineModel<number>("upper");
7
17
 
8
18
  const props = defineProps<{
9
19
  label?: string;
@@ -21,6 +31,37 @@ const props = defineProps<{
21
31
  decimals?: number;
22
32
  }>();
23
33
 
34
+ function isRangeValue(v: unknown): v is NumberRange {
35
+ return Array.isArray(v) && v.length === 2;
36
+ }
37
+
38
+ // Auto-detect range mode from the parent's v-model bindings. We check both
39
+ // the listener (which Vue attaches as `onUpdate:<name>` on the vnode) and
40
+ // the initial value — that way one-way `:range="x"` bindings also work.
41
+ // Determined once at setup; mode doesn't change for the component's life.
42
+ const instance = getCurrentInstance();
43
+ const vnodeProps = instance?.vnode.props;
44
+ const isRange =
45
+ !!vnodeProps?.["onUpdate:range"] ||
46
+ !!vnodeProps?.["onUpdate:lower"] ||
47
+ !!vnodeProps?.["onUpdate:upper"] ||
48
+ range.value !== undefined ||
49
+ lower.value !== undefined ||
50
+ upper.value !== undefined;
51
+
52
+ // Range implies slider — a two-handle range has no sensible text-input form.
53
+ const isSlider = computed(() => !!props.slider || isRange);
54
+
55
+ // Warn if the parent bound the default `v-model` in range mode — it will
56
+ // never receive updates, which is almost always a bug.
57
+ onMounted(() => {
58
+ if (isRange && !!vnodeProps?.["onUpdate:modelValue"]) {
59
+ console.warn(
60
+ "[NumberInput] In range mode, the default `v-model` is unused. Bind `v-model:range` or `v-model:lower`/`v-model:upper` instead.",
61
+ );
62
+ }
63
+ });
64
+
24
65
  const sliderMin = computed(() => props.min ?? (props.percent ? 0 : 0));
25
66
  const sliderMax = computed(() => props.max ?? (props.percent ? 1 : 100));
26
67
  const sliderStep = computed(() => props.step ?? (props.percent ? 0.01 : 1));
@@ -121,13 +162,50 @@ function stripCommas(s: string): string {
121
162
  return s.replace(/,/g, "");
122
163
  }
123
164
 
124
- const local = ref(formatForDisplay(toDisplay(model.value)));
125
- const sliderLocal = ref(model.value);
165
+ // Resolve the current value across all bindings:
166
+ // - In range mode: lower/upper take precedence; falls back per-side to
167
+ // `range`; finally to slider min/max. The default `v-model` is
168
+ // unused in this mode.
169
+ // - In single mode: just the default `v-model`.
170
+ function effectiveValue(): number | NumberRange | undefined {
171
+ if (isRange) {
172
+ const tuple = range.value;
173
+ const lo = lower.value ?? tuple?.[0];
174
+ const hi = upper.value ?? tuple?.[1];
175
+ if (lo !== undefined || hi !== undefined) {
176
+ return [lo ?? sliderMin.value, hi ?? sliderMax.value];
177
+ }
178
+ return undefined;
179
+ }
180
+ return model.value;
181
+ }
182
+
183
+ // Initial single-value display string. The text input isn't rendered in
184
+ // range mode, so `local` is only consulted in single mode.
185
+ const initialEffective = effectiveValue();
186
+ const initialSingle =
187
+ typeof initialEffective === "number" ? initialEffective : undefined;
188
+ const local = ref(formatForDisplay(toDisplay(initialSingle)));
189
+
190
+ // Slider state is always an array, even in single mode (reka-ui's API).
191
+ // In range mode it holds [low, high]; in single mode it holds [value].
192
+ function modelToSliderArray(v: number | NumberRange | undefined): number[] {
193
+ if (isRange) {
194
+ if (isRangeValue(v)) return [v[0], v[1]];
195
+ return [sliderMin.value, sliderMax.value];
196
+ }
197
+ if (typeof v === "number") return [v];
198
+ return [sliderMin.value];
199
+ }
200
+ const sliderArrayLocal = ref<number[]>(modelToSliderArray(initialEffective));
126
201
  const validationError = ref<string>();
127
202
 
128
- watch(model, (v) => {
129
- local.value = formatForDisplay(toDisplay(v));
130
- sliderLocal.value = v;
203
+ watch([model, range, lower, upper], () => {
204
+ const v = effectiveValue();
205
+ if (!isRange && !isRangeValue(v)) {
206
+ local.value = formatForDisplay(toDisplay(v as number | undefined));
207
+ }
208
+ sliderArrayLocal.value = modelToSliderArray(v);
131
209
  validationError.value = validate(v);
132
210
  });
133
211
 
@@ -179,21 +257,17 @@ function onBlur() {
179
257
 
180
258
  let liveTimeout: ReturnType<typeof setTimeout> | null = null;
181
259
  function onInputEvent() {
182
- if (!props.live || props.slider) return;
260
+ if (!props.live || isSlider.value) return;
183
261
  if (liveTimeout) clearTimeout(liveTimeout);
184
262
  liveTimeout = setTimeout(commit, 300);
185
263
  }
186
264
  function onChangeEvent() {
187
- if (!props.live || props.slider) return;
265
+ if (!props.live || isSlider.value) return;
188
266
  if (liveTimeout) clearTimeout(liveTimeout);
189
267
  commit();
190
268
  }
191
269
 
192
- // Validates a model value (or undefined for empty). Single source of truth
193
- // for required / min / max errors — used on commit, programmatic updates,
194
- // and arrow-key stepping.
195
- function validate(v: number | undefined): string | undefined {
196
- if (v == null) return props.required ? "Required" : undefined;
270
+ function validateScalar(v: number): string | undefined {
197
271
  const display = toDisplay(v) as number;
198
272
  if (inputMin.value != null && display < inputMin.value) {
199
273
  return `Min ${inputMin.value}${props.percent ? "%" : ""}`;
@@ -204,11 +278,28 @@ function validate(v: number | undefined): string | undefined {
204
278
  return undefined;
205
279
  }
206
280
 
281
+ // Single source of truth for required / min / max errors — used on commit,
282
+ // programmatic updates, and arrow-key stepping. In range mode, returns the
283
+ // first failing handle's error, suffixed with "(lower)" or "(upper)".
284
+ function validate(v: number | NumberRange | undefined): string | undefined {
285
+ if (v == null) return props.required ? "Required" : undefined;
286
+ if (isRangeValue(v)) {
287
+ const lo = validateScalar(v[0]);
288
+ if (lo) return `${lo} (lower)`;
289
+ const hi = validateScalar(v[1]);
290
+ if (hi) return `${hi} (upper)`;
291
+ return undefined;
292
+ }
293
+ return validateScalar(v);
294
+ }
295
+
207
296
  function commit() {
208
- // An empty field clears the model distinct from garbage input.
297
+ // commit() is only reachable when !isSlider (only text-input events call
298
+ // it). Default `v-model` is scalar-only.
299
+ const current = model.value;
209
300
  if (local.value.trim() === "") {
210
301
  model.value = undefined;
211
- sliderLocal.value = undefined;
302
+ sliderArrayLocal.value = modelToSliderArray(undefined);
212
303
  validationError.value = validate(undefined);
213
304
  return;
214
305
  }
@@ -219,8 +310,8 @@ function commit() {
219
310
  // turn pure garbage ("abc") into 0. Reset to the current model value so
220
311
  // invalid input doesn't linger in the field.
221
312
  if (!/\d/.test(cleaned)) {
222
- local.value = formatForDisplay(toDisplay(model.value));
223
- validationError.value = validate(model.value);
313
+ local.value = formatForDisplay(toDisplay(current));
314
+ validationError.value = validate(current);
224
315
  return;
225
316
  }
226
317
  if (cleaned !== local.value) {
@@ -240,22 +331,43 @@ function commit() {
240
331
  if (error) return;
241
332
 
242
333
  model.value = next;
243
- sliderLocal.value = model.value;
334
+ sliderArrayLocal.value = [next];
335
+ }
336
+
337
+ function commitSliderArray(v: number[], asModel: boolean): void {
338
+ const coerced = v.map(coerceInteger);
339
+ sliderArrayLocal.value = coerced;
340
+ if (!isRange) {
341
+ local.value = formatForDisplay(toDisplay(coerced[0]));
342
+ }
343
+ if (asModel) {
344
+ if (isRange) {
345
+ // Emit to all range sinks; consumers without a matching v-model just
346
+ // ignore their `update:*` event. The default `v-model` is unused in
347
+ // range mode.
348
+ range.value = [coerced[0], coerced[1]] as NumberRange;
349
+ lower.value = coerced[0];
350
+ upper.value = coerced[1];
351
+ } else {
352
+ model.value = coerced[0];
353
+ }
354
+ }
355
+ }
356
+
357
+ function thumbAriaLabel(i: number): string | undefined {
358
+ if (!props.label) return undefined;
359
+ if (!isRange) return props.label;
360
+ return i === 0 ? `${props.label} (lower)` : `${props.label} (upper)`;
244
361
  }
245
362
 
246
363
  function onSliderUpdate(v: number[] | undefined) {
247
364
  if (!v) return;
248
- const val = coerceInteger(v[0]);
249
- sliderLocal.value = val;
250
- local.value = formatForDisplay(toDisplay(val));
251
- if (props.live) {
252
- model.value = val;
253
- }
365
+ commitSliderArray(v, !!props.live);
254
366
  }
255
367
 
256
368
  function onSliderCommit(v: number[] | undefined) {
257
369
  if (!v) return;
258
- model.value = coerceInteger(v[0]);
370
+ commitSliderArray(v, true);
259
371
  }
260
372
 
261
373
  function onArrowStep(event: KeyboardEvent, direction: 1 | -1) {
@@ -268,71 +380,26 @@ function onArrowStep(event: KeyboardEvent, direction: 1 | -1) {
268
380
  if (inputMin.value != null) next = Math.max(next, inputMin.value);
269
381
  if (inputMax.value != null) next = Math.min(next, inputMax.value);
270
382
  local.value = formatForDisplay(next);
271
- model.value = fromDisplay(next);
272
- sliderLocal.value = model.value;
383
+ const nextModel = fromDisplay(next);
384
+ model.value = nextModel;
385
+ sliderArrayLocal.value = [nextModel];
273
386
  }
274
387
  </script>
275
388
 
276
389
  <template>
277
- <label v-if="props.label" class="input-label">
390
+ <component
391
+ :is="props.label ? 'label' : 'div'"
392
+ :class="props.label ? 'input-label' : undefined"
393
+ >
278
394
  <span
395
+ v-if="props.label"
279
396
  class="input-label-row"
280
397
  :class="{ 'visually-hidden': props.hideLabel }"
281
398
  >
282
399
  {{ props.label }}
283
400
  <Hint v-if="props.hint && !props.hideLabel" :text="props.hint" />
284
401
  </span>
285
- <span v-if="!props.slider" class="input-wrapper">
286
- <input
287
- type="text"
288
- :inputmode="props.numberType === 'integer' ? 'numeric' : 'decimal'"
289
- v-model="local"
290
- :placeholder="props.placeholder"
291
- :aria-invalid="!!validationError"
292
- :aria-required="props.required || undefined"
293
- :required="props.required"
294
- @blur="onBlur"
295
- @keydown.enter="commit"
296
- @keydown.up="onArrowStep($event, 1)"
297
- @keydown.down="onArrowStep($event, -1)"
298
- @input="
299
- reformatInput($event);
300
- onInputEvent();
301
- "
302
- @change="onChangeEvent"
303
- />
304
- <span v-if="props.percent" class="input-suffix">%</span>
305
- </span>
306
- <span v-if="validationError" class="input-error" role="alert">
307
- {{ validationError }}
308
- </span>
309
- <div v-if="props.slider" class="slider-container">
310
- <SliderRoot
311
- class="slider-root"
312
- :model-value="sliderLocal != null ? [sliderLocal] : [sliderMin]"
313
- :min="sliderMin"
314
- :max="sliderMax"
315
- :step="sliderStep"
316
- @update:model-value="onSliderUpdate"
317
- @value-commit="onSliderCommit"
318
- >
319
- <SliderTrack class="slider-track">
320
- <SliderRange class="slider-range" />
321
- </SliderTrack>
322
- <SliderThumb class="slider-thumb" :aria-label="props.label">
323
- <span class="slider-current">
324
- {{ formatSliderValue(sliderLocal) }}
325
- </span>
326
- </SliderThumb>
327
- </SliderRoot>
328
- <div class="slider-labels">
329
- <span>{{ formatSliderValue(sliderMin) }}</span>
330
- <span>{{ formatSliderValue(sliderMax) }}</span>
331
- </div>
332
- </div>
333
- </label>
334
- <div v-else>
335
- <span v-if="!props.slider" class="input-wrapper">
402
+ <span v-if="!isSlider" class="input-wrapper">
336
403
  <input
337
404
  type="text"
338
405
  :inputmode="props.numberType === 'integer' ? 'numeric' : 'decimal'"
@@ -356,10 +423,10 @@ function onArrowStep(event: KeyboardEvent, direction: 1 | -1) {
356
423
  <span v-if="validationError" class="input-error" role="alert">
357
424
  {{ validationError }}
358
425
  </span>
359
- <div v-if="props.slider" class="slider-container">
426
+ <div v-if="isSlider" class="slider-container">
360
427
  <SliderRoot
361
428
  class="slider-root"
362
- :model-value="sliderLocal != null ? [sliderLocal] : [sliderMin]"
429
+ :model-value="sliderArrayLocal"
363
430
  :min="sliderMin"
364
431
  :max="sliderMax"
365
432
  :step="sliderStep"
@@ -369,9 +436,14 @@ function onArrowStep(event: KeyboardEvent, direction: 1 | -1) {
369
436
  <SliderTrack class="slider-track">
370
437
  <SliderRange class="slider-range" />
371
438
  </SliderTrack>
372
- <SliderThumb class="slider-thumb" :aria-label="props.label">
439
+ <SliderThumb
440
+ v-for="(v, i) in sliderArrayLocal"
441
+ :key="i"
442
+ class="slider-thumb"
443
+ :aria-label="thumbAriaLabel(i)"
444
+ >
373
445
  <span class="slider-current">
374
- {{ formatSliderValue(sliderLocal) }}
446
+ {{ formatSliderValue(v) }}
375
447
  </span>
376
448
  </SliderThumb>
377
449
  </SliderRoot>
@@ -380,7 +452,7 @@ function onArrowStep(event: KeyboardEvent, direction: 1 | -1) {
380
452
  <span>{{ formatSliderValue(sliderMax) }}</span>
381
453
  </div>
382
454
  </div>
383
- </div>
455
+ </component>
384
456
  </template>
385
457
 
386
458
  <style scoped>
@@ -0,0 +1,17 @@
1
+ export type GapToken = "none" | "small" | "medium" | "large";
2
+
3
+ const GAP_TOKENS: Record<GapToken, string> = {
4
+ none: "0",
5
+ small: "var(--space-2)",
6
+ medium: "var(--space-4)",
7
+ large: "var(--space-6)",
8
+ };
9
+
10
+ export function resolveGap(
11
+ gap: GapToken | string | undefined,
12
+ fallback: GapToken = "medium",
13
+ ): string {
14
+ if (gap == null) return GAP_TOKENS[fallback];
15
+ if (gap in GAP_TOKENS) return GAP_TOKENS[gap as GapToken];
16
+ return gap;
17
+ }
@@ -1,11 +1,16 @@
1
1
  export { default as Box } from "./Box/Box.vue";
2
2
  export type { BoxVariant } from "./Box/Box.vue";
3
3
  export { default as Button } from "./Button/Button.vue";
4
+ export { default as Container } from "./Container/Container.vue";
5
+ export type { ContainerGap } from "./Container/Container.vue";
4
6
  export { default as Expander } from "./Expander/Expander.vue";
7
+ export { default as Grid } from "./Grid/Grid.vue";
8
+ export type { GridCols, GridGap } from "./Grid/Grid.vue";
5
9
  export { default as Hint } from "./Hint/Hint.vue";
6
10
  export { default as Icon } from "./Icon/Icon.vue";
7
11
  export { default as LightDarkToggle } from "./LightDarkToggle/LightDarkToggle.vue";
8
12
  export { default as NumberInput } from "./NumberInput/NumberInput.vue";
13
+ export type { NumberRange } from "./NumberInput/NumberInput.vue";
9
14
  export { default as SelectBox } from "./SelectBox/SelectBox.vue";
10
15
  export type { SelectOption } from "./SelectBox/SelectBox.vue";
11
16
  export { default as SidebarLayout } from "./SidebarLayout/SidebarLayout.vue";
package/index.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.3.15",
2
+ "version": "0.3.17",
3
3
  "package": "@cfasim-ui/docs",
4
4
  "content": {
5
5
  "components": [
@@ -23,6 +23,22 @@
23
23
  "secondary"
24
24
  ]
25
25
  },
26
+ {
27
+ "name": "Container",
28
+ "slug": "container",
29
+ "docs": "components/Container/Container.md",
30
+ "source": "components/Container/Container.vue",
31
+ "keywords": [
32
+ "container",
33
+ "layout",
34
+ "stack",
35
+ "row",
36
+ "flex",
37
+ "scrollable",
38
+ "border",
39
+ "card"
40
+ ]
41
+ },
26
42
  {
27
43
  "name": "Expander",
28
44
  "slug": "expander",
@@ -30,6 +46,22 @@
30
46
  "source": "components/Expander/Expander.vue",
31
47
  "keywords": []
32
48
  },
49
+ {
50
+ "name": "Grid",
51
+ "slug": "grid",
52
+ "docs": "components/Grid/Grid.md",
53
+ "source": "components/Grid/Grid.vue",
54
+ "keywords": [
55
+ "grid",
56
+ "columns",
57
+ "layout",
58
+ "responsive",
59
+ "auto-fit",
60
+ "cards",
61
+ "side-by-side",
62
+ "proportional"
63
+ ]
64
+ },
33
65
  {
34
66
  "name": "Hint",
35
67
  "slug": "hint",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cfasim-ui/docs",
3
- "version": "0.3.15",
3
+ "version": "0.3.17",
4
4
  "description": "LLM-friendly component and chart documentation for cfasim-ui",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
package/theme/base.css CHANGED
@@ -129,11 +129,6 @@ body {
129
129
  font-size: var(--font-size-md);
130
130
  }
131
131
 
132
- :root,
133
- body {
134
- overflow: hidden;
135
- }
136
-
137
132
  h1,
138
133
  h2,
139
134
  h3,
@@ -8,6 +8,7 @@
8
8
  --font-weight-heading: 200;
9
9
  --font-size-md: 1.125rem;
10
10
  --font-size-2xl: 2.625rem;
11
+ --color-text: light-dark(#1c1d1f, #f8f9fa);
11
12
  --color-bg-0: light-dark(#ffffff, #0f1e21);
12
13
  --color-bg-1: light-dark(#f4fbfc, #162a2e);
13
14
  --color-bg-2: light-dark(#e9f5f8, #1e373c);
@@ -20,3 +21,15 @@
20
21
  --color-border-hover: light-dark(#a0d4e0, #3a5d64);
21
22
  --color-border-focus: var(--color-primary);
22
23
  }
24
+
25
+ [data-theme="cdc"] a {
26
+ text-decoration: underline;
27
+ text-decoration-thickness: 1px;
28
+ text-underline-offset: 0.25rem;
29
+ }
30
+
31
+ [data-theme="cdc"] a:hover,
32
+ [data-theme="cdc"] a:focus,
33
+ [data-theme="cdc"] a:active {
34
+ color: light-dark(#007a99, #7dd6e8);
35
+ }