@cfasim-ui/docs 0.6.0 → 0.6.2

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.
@@ -258,6 +258,8 @@ Use an array of `CategoricalStop` objects to map string values to colors. Each s
258
258
 
259
259
  Set `geoType="counties"` to render county-level data using 5-digit FIPS codes. State borders are drawn on top for context. Use `pan` and `zoom` props to enable interactive navigation — useful for dense county data.
260
260
 
261
+ On touch devices, add `two-finger-pan` so a single finger scrolls the page (the map sets `touch-action: pan-y`) and two fingers pan/pinch-zoom the map — tap-to-select still works with one finger.
262
+
261
263
  <ComponentDemo>
262
264
  <ChoroplethMap
263
265
  :topology="countiesTopo"
@@ -356,6 +358,58 @@ Set `geoType="hsas"` to render Health Service Area boundaries. HSAs are dissolve
356
358
  </template>
357
359
  </ComponentDemo>
358
360
 
361
+ ### Single-state map (`state`)
362
+
363
+ Set the `state` prop to render just one state's outline with its `counties` or
364
+ `hsas` inside it — no surrounding states — and the projection zooms to fit it.
365
+ Accepts a state **name** (`"California"`) or a 2-digit **FIPS code** (`"06"`).
366
+ This needs a counties topology (the same `us-atlas/counties-10m.json`). `data`
367
+ can stay national; only features inside the selected state are drawn and
368
+ colored.
369
+
370
+ <ComponentDemo>
371
+ <ChoroplethMap
372
+ :topology="countiesTopo"
373
+ geo-type="counties"
374
+ state="California"
375
+ :data="[
376
+ { id: '06037', value: 100 },
377
+ { id: '06073', value: 80 },
378
+ { id: '06059', value: 65 },
379
+ { id: '06065', value: 55 },
380
+ { id: '06001', value: 45 },
381
+ { id: '06085', value: 40 },
382
+ ]"
383
+ title="California cases by county"
384
+ :legend-title="'Cases'"
385
+ :height="400"
386
+ />
387
+
388
+ <template #code>
389
+
390
+ ```vue
391
+ <ChoroplethMap
392
+ :topology="countiesTopo"
393
+ geo-type="counties"
394
+ state="California"
395
+ :data="[
396
+ { id: '06037', value: 100 }, // Los Angeles
397
+ { id: '06073', value: 80 }, // San Diego
398
+ { id: '06059', value: 65 }, // Orange
399
+ ]"
400
+ title="California cases by county"
401
+ :legend-title="'Cases'"
402
+ :height="400"
403
+ />
404
+ ```
405
+
406
+ </template>
407
+ </ComponentDemo>
408
+
409
+ Switch `geo-type="hsas"` to fill the same state with Health Service Areas
410
+ instead. An unrecognized `state` value logs a warning and falls back to the
411
+ full national map.
412
+
359
413
  ### Click to focus (`v-model:focus`)
360
414
 
361
415
  Bind the `focus` prop to pan and zoom to a specific feature. Pass a feature
@@ -365,6 +419,10 @@ the focused feature toggles back off. If a tooltip is configured, focusing
365
419
  shows that feature's tooltip. Users can pan/zoom freely around the focused
366
420
  area; the built-in **Reset** button clears focus and snaps back.
367
421
 
422
+ Set `:focus-zoom="false"` to highlight (and draw cross-geoType overlays)
423
+ **without** panning or zooming — useful for a click-to-select interaction
424
+ where the map should stay put while a side panel shows the details.
425
+
368
426
  Selection works on touch too: a single-finger **tap** on a feature emits
369
427
  `stateClick` and toggles focus, while drags (pan) and pinches (zoom) are left
370
428
  to the map. Hover tooltips stay off on touch for performance.
@@ -695,6 +753,7 @@ set `tooltip-trigger`.
695
753
  | `data` | `StateData[]` | No | — |
696
754
  | `geoType` | `GeoType` | No | `"states"` |
697
755
  | `dataGeoType` | `GeoType` | No | — |
756
+ | `state` | `string` | No | — |
698
757
  | `width` | `number` | No | — |
699
758
  | `height` | `number` | No | — |
700
759
  | `colorScale` | `ChoroplethColorScale \| ThresholdStop[] \| CategoricalStop[]` | No | — |
@@ -709,6 +768,7 @@ set `tooltip-trigger`.
709
768
  | `legendTitle` | `string` | No | — |
710
769
  | `zoom` | `boolean` | No | `false` |
711
770
  | `pan` | `boolean` | No | `false` |
771
+ | `twoFingerPan` | `boolean` | No | `false` |
712
772
  | `tooltipTrigger` | `"hover" \| "click"` | No | — |
713
773
  | `tooltipFormat` | `(data: {
714
774
  id: string` | No | — |
@@ -718,6 +778,7 @@ set `tooltip-trigger`.
718
778
  | `tooltipClamp` | `"none" \| "chart" \| "window"` | No | `"chart"` |
719
779
  | `focus` | `FocusValue` | No | — |
720
780
  | `focusZoomLevel` | `number` | No | `4` |
781
+ | `focusZoom` | `boolean` | No | `true` |
721
782
  | `fullscreenTarget` | `string \| HTMLElement` | No | — |
722
783
 
723
784
 
@@ -8,7 +8,7 @@ import {
8
8
  toRaw,
9
9
  useSlots,
10
10
  } from "vue";
11
- import { geoPath, geoAlbersUsa } from "d3-geo";
11
+ import { geoPath, geoAlbersUsa, geoMercator } from "d3-geo";
12
12
  import { zoom as d3Zoom, zoomIdentity } from "d3-zoom";
13
13
  import { select } from "d3-selection";
14
14
  // Side-effect import: enables `selection.transition()` on d3 selections so
@@ -107,6 +107,15 @@ const props = withDefaults(
107
107
  * When unset, data ids must match the base `geoType`.
108
108
  */
109
109
  dataGeoType?: GeoType;
110
+ /**
111
+ * Scope the map to a single state: render only that state's outline with
112
+ * its `counties` or `hsas` inside it (no surrounding states), and refit
113
+ * the projection to zoom to it. Accepts a state name ("California") or a
114
+ * 2-digit FIPS code ("06"). Requires a counties topology when `geoType`
115
+ * is `"counties"` or `"hsas"`. If the value matches no state, the full
116
+ * national map is rendered and a warning is logged.
117
+ */
118
+ state?: string;
110
119
  width?: number;
111
120
  height?: number;
112
121
  colorScale?: ChoroplethColorScale | ThresholdStop[] | CategoricalStop[];
@@ -131,6 +140,14 @@ const props = withDefaults(
131
140
  zoom?: boolean;
132
141
  /** Enable click-and-drag panning. Default: false */
133
142
  pan?: boolean;
143
+ /**
144
+ * Require two fingers to pan/zoom on touch devices. Default `false`.
145
+ * When `true`, a single finger scrolls the page (the map sets
146
+ * `touch-action: pan-y` and ignores one-finger drags) while two fingers
147
+ * pan and pinch-zoom the map. Single-finger tap-to-select still works.
148
+ * Has no effect with a mouse.
149
+ */
150
+ twoFingerPan?: boolean;
134
151
  /** Tooltip activation mode */
135
152
  tooltipTrigger?: "hover" | "click";
136
153
  /**
@@ -174,6 +191,14 @@ const props = withDefaults(
174
191
  focus?: FocusValue;
175
192
  /** Scale factor applied when `focus` is set. Default: 4 */
176
193
  focusZoomLevel?: number;
194
+ /**
195
+ * Whether setting `focus` pans/zooms to fit the focused feature.
196
+ * Default `true`. Set `false` to highlight (and draw cross-geoType
197
+ * overlays) without changing the current pan/zoom — useful for a
198
+ * click-to-select interaction where the map should stay put. The
199
+ * built-in Reset button is unaffected.
200
+ */
201
+ focusZoom?: boolean;
177
202
  /**
178
203
  * Where to teleport the map while expanded (the Expand menu item). A
179
204
  * CSS selector or element; defaults to `body`. Moving it to the
@@ -193,8 +218,10 @@ const props = withDefaults(
193
218
  legend: true,
194
219
  zoom: false,
195
220
  pan: false,
221
+ twoFingerPan: false,
196
222
  tooltipClamp: "chart",
197
223
  focusZoomLevel: 4,
224
+ focusZoom: true,
198
225
  },
199
226
  );
200
227
 
@@ -425,16 +452,28 @@ function setupZoom() {
425
452
  // Dynamic filter: re-evaluated per event, so toggling `focus`,
426
453
  // `zoom`, or `pan` doesn't require tearing down the zoom behavior.
427
454
  // When focus is active we always allow drag + wheel so users can
428
- // explore away from the focused area regardless of `zoom`/`pan`.
455
+ // explore away from the focused area regardless of `zoom`/`pan` — but
456
+ // not in highlight-only mode (`focusZoom=false`), where focus is purely
457
+ // visual and shouldn't grant interaction the props didn't ask for.
429
458
  // Programmatic `.transform()` calls bypass this filter entirely.
430
459
  zoomBehavior.filter((event) => {
431
- const focused = normalizedFocus.value.length > 0;
460
+ const focused =
461
+ normalizedFocus.value.length > 0 && props.focusZoom !== false;
432
462
  const allowZoom = !!props.zoom || focused;
433
463
  const allowPan = !!props.pan || focused;
434
464
  if (event.type === "wheel" || event.type === "dblclick") {
435
465
  if (!allowZoom) return false;
436
466
  } else if (event.type === "mousedown" || event.type === "touchstart") {
437
467
  if (!allowPan) return false;
468
+ // Two-finger-only mode: ignore single-finger touches so the page can
469
+ // scroll; a second finger drives the map (pan + pinch-zoom).
470
+ if (
471
+ props.twoFingerPan &&
472
+ event.type === "touchstart" &&
473
+ (event as TouchEvent).touches.length < 2
474
+ ) {
475
+ return false;
476
+ }
438
477
  } else if (!allowZoom && !allowPan) {
439
478
  return false;
440
479
  }
@@ -541,6 +580,13 @@ function applyFocus() {
541
580
  return;
542
581
  }
543
582
 
583
+ // Highlight-only mode: the strokes + overlays above are applied; skip the
584
+ // pan/zoom transform so the current view stays put (click-to-select).
585
+ if (props.focusZoom === false) {
586
+ focusApplied = true;
587
+ return;
588
+ }
589
+
544
590
  const svg = select(svgRef.value);
545
591
  // Always cancel any in-flight transition first — d3-transition queues
546
592
  // same-named transitions rather than replacing them, so rapid focus
@@ -667,6 +713,19 @@ watch(
667
713
  },
668
714
  );
669
715
 
716
+ // Switching the scoped `state` refits the projection to a different region,
717
+ // so any leftover pan/zoom transform would leave the new map off-center.
718
+ // Reset it instantly (no animation) when the region changes.
719
+ watch(
720
+ () => props.state,
721
+ () => {
722
+ if (!svgRef.value || !zoomBehavior) return;
723
+ const svg = select(svgRef.value);
724
+ svg.interrupt();
725
+ zoomBehavior.transform(svg, zoomIdentity);
726
+ },
727
+ );
728
+
670
729
  // Canonical internal coordinate system. All layout (projection, legend,
671
730
  // title) is computed at this size; the SVG's viewBox makes the browser
672
731
  // scale the entire canvas to whatever the container provides, so there's no
@@ -692,6 +751,58 @@ type CountiesTopo = Topology<{
692
751
  states: NamedGeometry;
693
752
  }>;
694
753
 
754
+ type StateFeature = GeoJSON.Feature<GeoJSON.Geometry | null, { name?: string }>;
755
+
756
+ // ─── Single-state scoping (`state` prop) ─────────────────────────────────
757
+ //
758
+ // Resolved directly from the topology's `states` object (not from
759
+ // featuresByGeoType) so it stays free of the featuresGeo → featuresById →
760
+ // featuresByGeoType chain — that chain reads `stateFips`, so depending on it
761
+ // here would form a reactive cycle.
762
+ const statesFeatures = computed<StateFeature[]>(() => {
763
+ const topo = toRaw(props.topology) as unknown as {
764
+ objects?: { states?: NamedGeometry };
765
+ };
766
+ const statesObj = topo?.objects?.states;
767
+ if (!statesObj) return [];
768
+ const fc = feature(topo as unknown as Topology, statesObj) as
769
+ | GeoJSON.FeatureCollection<GeoJSON.Geometry | null, { name?: string }>
770
+ | StateFeature;
771
+ return fc.type === "FeatureCollection" ? fc.features : [fc];
772
+ });
773
+
774
+ // 2-digit FIPS for the active `state` prop, or null when unset/unresolved.
775
+ const stateFips = computed<string | null>(() => {
776
+ const s = props.state?.trim();
777
+ if (!s) return null;
778
+ if (/^\d{1,2}$/.test(s)) return s.padStart(2, "0");
779
+ const match = statesFeatures.value.find((f) => f.properties?.name === s);
780
+ return match?.id != null ? String(match.id).padStart(2, "0") : null;
781
+ });
782
+
783
+ // The single state's GeoJSON feature — drives the outline path and the
784
+ // projection fit in single-state mode.
785
+ const stateOutlineFeature = computed<StateFeature | null>(() => {
786
+ const fips = stateFips.value;
787
+ if (!fips) return null;
788
+ return (
789
+ statesFeatures.value.find((f) => String(f.id).padStart(2, "0") === fips) ??
790
+ null
791
+ );
792
+ });
793
+
794
+ watch(
795
+ () => [props.state, stateFips.value] as const,
796
+ ([state, fips]) => {
797
+ if (state && state.trim() && !fips) {
798
+ console.warn(
799
+ `[ChoroplethMap] state="${state}" matched no state name or FIPS code; rendering the full map.`,
800
+ );
801
+ }
802
+ },
803
+ { immediate: true },
804
+ );
805
+
695
806
  // HSA mapping is loaded lazily — it's ~25KB gzipped and only needed when
696
807
  // geoType or dataGeoType is "hsas". Keeps the main bundle small for users
697
808
  // who only need states/counties maps.
@@ -727,10 +838,14 @@ const hsaFeaturesGeo = computed(() => {
727
838
  const { fipsToHsa, hsaNames } = mod;
728
839
  const topo = toRaw(props.topology) as unknown as CountiesTopo;
729
840
  const countyGeometries = topo.objects.counties.geometries;
841
+ const scopeFips = stateFips.value;
730
842
  const groups = new Map<string, typeof countyGeometries>();
731
843
 
732
844
  for (const geom of countyGeometries) {
733
845
  const fips = String(geom.id).padStart(5, "0");
846
+ // Single-state mode: drop counties outside the state before grouping, so
847
+ // the resulting HSAs only cover the selected state.
848
+ if (scopeFips && fips.slice(0, 2) !== scopeFips) continue;
734
849
  const hsaCode = fipsToHsa[fips];
735
850
  if (!hsaCode) continue;
736
851
  if (!groups.has(hsaCode)) groups.set(hsaCode, []);
@@ -751,30 +866,71 @@ const hsaFeaturesGeo = computed(() => {
751
866
  });
752
867
 
753
868
  const featuresGeo = computed(() => {
869
+ // hsaFeaturesGeo already honors `state` (it scopes the source counties).
754
870
  if (props.geoType === "hsas") return hsaFeaturesGeo.value;
871
+ const scopeFips = stateFips.value;
755
872
  if (props.geoType === "counties") {
756
873
  const topo = toRaw(props.topology) as unknown as CountiesTopo;
757
- return feature(topo, topo.objects.counties);
874
+ const fc = feature(topo, topo.objects.counties);
875
+ if (!scopeFips) return fc;
876
+ return {
877
+ type: "FeatureCollection" as const,
878
+ features: fc.features.filter(
879
+ (f) => String(f.id).padStart(5, "0").slice(0, 2) === scopeFips,
880
+ ),
881
+ };
758
882
  }
759
883
  const topo = toRaw(props.topology) as unknown as StatesTopo;
760
- return feature(topo, topo.objects.states);
884
+ const fc = feature(topo, topo.objects.states);
885
+ if (!scopeFips) return fc;
886
+ return {
887
+ type: "FeatureCollection" as const,
888
+ features: fc.features.filter(
889
+ (f) => String(f.id).padStart(2, "0") === scopeFips,
890
+ ),
891
+ };
761
892
  });
762
893
 
763
894
  const stateBordersPath = computed(() => {
764
895
  if (props.geoType !== "counties" && props.geoType !== "hsas") return null;
896
+ // Single-state mode: trace just the selected state's outline instead of
897
+ // the full national state-border mesh.
898
+ if (stateFips.value) return stateOutlineFeature.value;
765
899
  const topo = toRaw(props.topology) as unknown as CountiesTopo;
766
900
  return mesh(topo, topo.objects.states, (a, b) => a !== b);
767
901
  });
768
902
 
769
- const projection = computed(() =>
770
- geoAlbersUsa().fitExtent(
903
+ // Breathing room (canonical px) around a single state so its outline isn't
904
+ // flush against the SVG edge. Only applied in single-state mode.
905
+ const STATE_FIT_INSET = 12;
906
+
907
+ const projection = computed(() => {
908
+ const outline = stateOutlineFeature.value;
909
+ if (stateFips.value && outline) {
910
+ const extent: [[number, number], [number, number]] = [
911
+ [STATE_FIT_INSET, STATE_FIT_INSET],
912
+ [width.value - STATE_FIT_INSET, height.value - STATE_FIT_INSET],
913
+ ];
914
+ const albers = geoAlbersUsa().fitExtent(extent, outline);
915
+ // geoAlbersUsa only covers the 50 states + DC (it handles Alaska and
916
+ // Hawaii via insets). The island territories — Puerto Rico, Guam, the
917
+ // US Virgin Islands, American Samoa, the N. Mariana Islands — fall
918
+ // outside it and project to null, so fitExtent yields a NaN transform
919
+ // and every path renders as "MNaN,NaN…". Detect that by projecting the
920
+ // outline's centroid and fall back to a plain Mercator that can render
921
+ // any region.
922
+ const c = geoPath(albers).centroid(outline);
923
+ if (Number.isFinite(c[0]) && Number.isFinite(c[1])) return albers;
924
+ return geoMercator().fitExtent(extent, outline);
925
+ }
926
+ return geoAlbersUsa().fitExtent(
771
927
  [
772
928
  [0, 0],
773
929
  [width.value, height.value],
774
930
  ],
775
931
  featuresGeo.value,
776
- ),
777
- );
932
+ );
933
+ });
778
934
 
779
935
  const pathGenerator = computed(() => geoPath(projection.value));
780
936
 
@@ -1481,9 +1637,12 @@ const menuItems = computed<ChartMenuItem[]>(() => {
1481
1637
  // Registered last so the eagerly-evaluated source getters can read every
1482
1638
  // computed defined above without hitting a TDZ.
1483
1639
 
1484
- // Geometry / projection / tooltip-mode → full rebuild.
1640
+ // Geometry / projection / tooltip-mode → full rebuild. `featuresGeo` is
1641
+ // watched explicitly because in single-state mode the projection is pinned
1642
+ // to the static state outline, so an async change to the feature set (e.g.
1643
+ // the lazy HSA module resolving) wouldn't otherwise change `pathGenerator`.
1485
1644
  watch(
1486
- () => [pathGenerator.value, hasInteractiveTooltip.value],
1645
+ () => [featuresGeo.value, pathGenerator.value, hasInteractiveTooltip.value],
1487
1646
  () => rebuildPaths(),
1488
1647
  );
1489
1648
 
@@ -1596,6 +1755,7 @@ watch(
1596
1755
  ref="svgRef"
1597
1756
  :viewBox="`0 0 ${width} ${height}`"
1598
1757
  preserveAspectRatio="xMidYMid meet"
1758
+ :style="twoFingerPan ? { touchAction: 'pan-y' } : undefined"
1599
1759
  >
1600
1760
  <!--
1601
1761
  Path elements are created imperatively in `rebuildPaths()`; Vue never
@@ -180,6 +180,68 @@ grows automatically for additional lines.
180
180
  </template>
181
181
  </ComponentDemo>
182
182
 
183
+ ### Font sizes
184
+
185
+ Every text element on the chart has its own style prop and default size (in px). The text-styling props (`axisLabelStyle`, `tickLabelStyle`, `legendStyle`) take a `LabelStyle` — `{ fontSize?, color?, fontWeight? }` — and `titleStyle` adds `lineHeight` and `align`. Unset fields fall back to the defaults below.
186
+
187
+ | Chart part | Prop / field | Type | Default size |
188
+ | ------------------------------------------- | ------------------------------------ | ------------ | -----------: |
189
+ | Title | `titleStyle` | `TitleStyle` | 14 |
190
+ | Axis labels (`xLabel` / `yLabel`) | `axisLabelStyle` | `LabelStyle` | 13 |
191
+ | Axis tick labels (the numbers on each axis) | `tickLabelStyle` | `LabelStyle` | 10 |
192
+ | Inline legend | `legendStyle` | `LabelStyle` | 11 |
193
+ | Area section label | `areaSection.inlineLabelStyle` | `LabelStyle` | 11 |
194
+ | Area section description | `areaSection.inlineDescriptionStyle` | `LabelStyle` | 11 |
195
+ | Annotation text | `annotation.fontSize` | `number` | 13 |
196
+
197
+ These are single styles applied to both axes — there's no separate x vs y font size. The same props (except the LineChart-only area-section fields) exist on `BarChart`, since both compose `ChartCommonProps`.
198
+
199
+ <ComponentDemo>
200
+ <LineChart
201
+ :series="[
202
+ { data: [0, 12, 28, 45, 60, 55, 40, 25, 12], color: '#0057b7', legend: 'No interventions' },
203
+ { data: [0, 8, 18, 30, 40, 35, 25, 14, 6], color: '#ef4444', legend: 'Masking + distancing' },
204
+ ]"
205
+ :height="240"
206
+ title="Daily ED visits"
207
+ x-label="Days"
208
+ y-label="Cases"
209
+ :title-style="{ fontSize: 18 }"
210
+ :axis-label-style="{ fontSize: 15, fontWeight: 700 }"
211
+ :tick-label-style="{ fontSize: 13 }"
212
+ :legend-style="{ fontSize: 14 }"
213
+ />
214
+
215
+ <template #code>
216
+
217
+ ```vue
218
+ <LineChart
219
+ :series="[
220
+ {
221
+ data: [0, 12, 28, 45, 60, 55, 40, 25, 12],
222
+ color: '#0057b7',
223
+ legend: 'No interventions',
224
+ },
225
+ {
226
+ data: [0, 8, 18, 30, 40, 35, 25, 14, 6],
227
+ color: '#ef4444',
228
+ legend: 'Masking + distancing',
229
+ },
230
+ ]"
231
+ :height="240"
232
+ title="Daily ED visits"
233
+ x-label="Days"
234
+ y-label="Cases"
235
+ :title-style="{ fontSize: 18 }"
236
+ :axis-label-style="{ fontSize: 15, fontWeight: 700 }"
237
+ :tick-label-style="{ fontSize: 13 }"
238
+ :legend-style="{ fontSize: 14 }"
239
+ />
240
+ ```
241
+
242
+ </template>
243
+ </ComponentDemo>
244
+
183
245
  ### Multiple series
184
246
 
185
247
  <ComponentDemo>
@@ -628,7 +690,9 @@ inverts. The white `outline`, if any, is unaffected.
628
690
  Use the `areas` prop to fill a band between two y-series — useful for
629
691
  confidence intervals or min/max envelopes around a mean line. Each `Area`
630
692
  takes parallel `upper` and `lower` arrays (and an optional `x` array, just
631
- like `Series`).
693
+ like `Series`). Set `blendMode` on an `Area` to apply a CSS
694
+ `mix-blend-mode` to its fill so overlapping bands combine their colors
695
+ (e.g. `"multiply"`) instead of the later one obscuring the earlier.
632
696
 
633
697
  <ComponentDemo>
634
698
  <LineChart
@@ -1103,6 +1167,7 @@ interface Area {
1103
1167
  x?: LineChartData; // optional parallel x-values
1104
1168
  color?: string;
1105
1169
  opacity?: number;
1170
+ blendMode?: BlendMode; // CSS mix-blend-mode for the fill
1106
1171
  }
1107
1172
  ```
1108
1173
 
@@ -114,6 +114,12 @@ export interface Area {
114
114
  x?: LineChartXInput;
115
115
  color?: string;
116
116
  opacity?: number;
117
+ /**
118
+ * CSS `mix-blend-mode` applied to the area fill. Lets overlapping
119
+ * areas (e.g. confidence bands) combine their colors instead of one
120
+ * obscuring the other.
121
+ */
122
+ blendMode?: BlendMode;
117
123
  }
118
124
 
119
125
  export interface AreaSection {
@@ -1218,6 +1224,7 @@ const positionedLegendItems = computed(() => {
1218
1224
  :fill="a.color ?? 'currentColor'"
1219
1225
  :fill-opacity="a.opacity ?? 0.2"
1220
1226
  stroke="none"
1227
+ :style="a.blendMode ? { mixBlendMode: a.blendMode } : undefined"
1221
1228
  />
1222
1229
  <!-- data lines and dots -->
1223
1230
  <template v-for="(s, i) in allSeries" :key="i">
@@ -1,12 +1,15 @@
1
1
  # SelectBox
2
2
 
3
- A dropdown select built on reka-ui.
3
+ A single-select dropdown built on reka-ui. Set `autocomplete` to turn it into a
4
+ filterable single-select combobox: type to narrow the options, just like
5
+ [MultiSelect](./multi-select) but bound to a single `string`.
4
6
 
5
7
  ## Examples
6
8
 
7
9
  <script setup>
8
10
  import { ref } from 'vue'
9
11
  const interval = ref('weekly')
12
+ const state = ref('')
10
13
  </script>
11
14
 
12
15
  <ComponentDemo>
@@ -83,6 +86,73 @@ DOM.
83
86
  </template>
84
87
  </ComponentDemo>
85
88
 
89
+ ### Autocomplete
90
+
91
+ Add `autocomplete` to make the field filterable. Type to narrow the list; the
92
+ selected option's label fills the input. `v-model` is still a single `string`.
93
+
94
+ <ComponentDemo>
95
+ <div style="width: 240px">
96
+ <SelectBox
97
+ v-model="state"
98
+ autocomplete
99
+ label="State"
100
+ placeholder="Search states…"
101
+ :options="[
102
+ { value: 'ca', label: 'California' },
103
+ { value: 'ny', label: 'New York' },
104
+ { value: 'tx', label: 'Texas' },
105
+ { value: 'wa', label: 'Washington' },
106
+ { value: 'fl', label: 'Florida' },
107
+ ]"
108
+ />
109
+ </div>
110
+
111
+ <template #code>
112
+
113
+ ```vue
114
+ <script setup>
115
+ import { ref } from "vue";
116
+ const state = ref("");
117
+ </script>
118
+
119
+ <SelectBox
120
+ v-model="state"
121
+ autocomplete
122
+ label="State"
123
+ placeholder="Search states…"
124
+ :options="[
125
+ { value: 'ca', label: 'California' },
126
+ { value: 'ny', label: 'New York' },
127
+ { value: 'tx', label: 'Texas' },
128
+ { value: 'wa', label: 'Washington' },
129
+ { value: 'fl', label: 'Florida' },
130
+ ]"
131
+ />
132
+ ```
133
+
134
+ </template>
135
+ </ComponentDemo>
136
+
137
+ ## Accessibility
138
+
139
+ In the default mode the field is a reka-ui Select; with `autocomplete` it's a
140
+ reka-ui Combobox following the
141
+ [ARIA combobox pattern](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/):
142
+
143
+ - The input has `role="combobox"` with `aria-expanded`, `aria-controls`, and
144
+ `aria-autocomplete="list"`; the popup is a `role="listbox"` and each option a
145
+ `role="option"` that reflects its state via `aria-selected`.
146
+ - Keyboard support is handled for you: type to filter, ↑/↓ to move through the
147
+ list, Enter to select the highlighted option, and Escape to close.
148
+ - Pass `label` so the field is named by a real `<label>`. In autocomplete mode
149
+ the label is also wired to the input via `for`/`id`, so clicking it focuses
150
+ the field; use `hide-label` to keep the label for screen readers while hiding
151
+ it visually, or `aria-label` when there's no visible label text.
152
+ - The chevron is a labelled toggle (`aria-label="Toggle options"`) kept out of
153
+ the tab order, and native browser autofill is disabled so it can't cover the
154
+ option list.
155
+
86
156
  ## Model
87
157
 
88
158
  | Name | Type |
@@ -98,6 +168,7 @@ DOM.
98
168
  | `ariaLabel` | `string` | No | — |
99
169
  | `options` | `SelectOption[]` | Yes | — |
100
170
  | `placeholder` | `string` | No | — |
171
+ | `autocomplete` | `boolean` | No | — |
101
172
 
102
173
 
103
174
  ### SelectOption
@@ -1,5 +1,15 @@
1
1
  <script setup lang="ts">
2
2
  import {
3
+ ComboboxAnchor,
4
+ ComboboxContent,
5
+ ComboboxEmpty,
6
+ ComboboxInput,
7
+ ComboboxItem,
8
+ ComboboxItemIndicator,
9
+ ComboboxPortal,
10
+ ComboboxRoot,
11
+ ComboboxTrigger,
12
+ ComboboxViewport,
3
13
  SelectContent,
4
14
  SelectItem,
5
15
  SelectItemIndicator,
@@ -25,9 +35,16 @@ const props = defineProps<{
25
35
  ariaLabel?: string;
26
36
  options: SelectOption[];
27
37
  placeholder?: string;
38
+ /** Turn the field into a filterable single-select autocomplete (combobox). */
39
+ autocomplete?: boolean;
28
40
  }>();
29
41
 
30
42
  const id = useId();
43
+
44
+ // Shows the selected option's label in the combobox input when not filtering.
45
+ function displayValue(value: string) {
46
+ return props.options.find((o) => o.value === value)?.label ?? "";
47
+ }
31
48
  </script>
32
49
 
33
50
  <template>
@@ -35,11 +52,81 @@ const id = useId();
35
52
  <label
36
53
  v-if="label"
37
54
  :id="`${id}-label`"
55
+ :for="autocomplete ? `${id}-input` : undefined"
38
56
  class="select-label"
39
57
  :class="{ 'visually-hidden': hideLabel }"
40
58
  >{{ label }}</label
41
59
  >
42
- <SelectRoot v-model="model">
60
+
61
+ <ComboboxRoot
62
+ v-if="autocomplete"
63
+ v-model="model"
64
+ open-on-click
65
+ class="select-combobox-root"
66
+ >
67
+ <ComboboxAnchor class="select-anchor">
68
+ <ComboboxInput
69
+ :id="`${id}-input`"
70
+ class="select-input"
71
+ :display-value="displayValue"
72
+ :placeholder="placeholder"
73
+ :aria-labelledby="props.label ? `${id}-label` : undefined"
74
+ :aria-label="!props.label ? props.ariaLabel : undefined"
75
+ />
76
+ <ComboboxTrigger class="select-icon-button" aria-label="Toggle options">
77
+ <span class="select-icon" aria-hidden="true">
78
+ <svg
79
+ width="12"
80
+ height="12"
81
+ viewBox="0 0 12 12"
82
+ fill="none"
83
+ stroke="currentColor"
84
+ stroke-width="2"
85
+ stroke-linecap="round"
86
+ stroke-linejoin="round"
87
+ >
88
+ <path d="M3 4.5L6 7.5L9 4.5" />
89
+ </svg>
90
+ </span>
91
+ </ComboboxTrigger>
92
+ </ComboboxAnchor>
93
+ <ComboboxPortal>
94
+ <ComboboxContent
95
+ class="select-content select-content-autocomplete"
96
+ position="popper"
97
+ :side-offset="4"
98
+ :body-lock="false"
99
+ >
100
+ <ComboboxViewport class="select-viewport">
101
+ <ComboboxEmpty class="select-empty">No matches</ComboboxEmpty>
102
+ <ComboboxItem
103
+ v-for="opt in options"
104
+ :key="opt.value"
105
+ :value="opt.value"
106
+ class="select-item"
107
+ >
108
+ <span>{{ opt.label }}</span>
109
+ <ComboboxItemIndicator class="select-indicator">
110
+ <svg
111
+ width="12"
112
+ height="12"
113
+ viewBox="0 0 12 12"
114
+ fill="none"
115
+ stroke="currentColor"
116
+ stroke-width="2"
117
+ stroke-linecap="round"
118
+ stroke-linejoin="round"
119
+ >
120
+ <path d="M2 6L5 9L10 3" />
121
+ </svg>
122
+ </ComboboxItemIndicator>
123
+ </ComboboxItem>
124
+ </ComboboxViewport>
125
+ </ComboboxContent>
126
+ </ComboboxPortal>
127
+ </ComboboxRoot>
128
+
129
+ <SelectRoot v-else v-model="model">
43
130
  <SelectTrigger
44
131
  class="select-trigger"
45
132
  :aria-labelledby="props.label ? `${id}-label` : undefined"
@@ -140,6 +227,62 @@ const id = useId();
140
227
  color: var(--color-text-secondary);
141
228
  }
142
229
 
230
+ /* Autocomplete (combobox) field */
231
+ .select-anchor {
232
+ display: flex;
233
+ align-items: center;
234
+ gap: 0.5em;
235
+ height: 2.5em;
236
+ padding: 0 0.5em 0 0.75em;
237
+ font-size: var(--font-size-sm);
238
+ background: var(--color-bg-0);
239
+ border: 1px solid var(--color-border);
240
+ border-radius: 0.375em;
241
+ transition:
242
+ border-color var(--transition-fast),
243
+ box-shadow var(--transition-fast);
244
+ }
245
+
246
+ .select-anchor:hover {
247
+ border-color: var(--color-border-hover);
248
+ }
249
+
250
+ .select-anchor:focus-within {
251
+ outline: none;
252
+ border-color: var(--color-border-focus);
253
+ box-shadow: var(--shadow-focus);
254
+ }
255
+
256
+ .select-input {
257
+ flex: 1;
258
+ min-width: 0;
259
+ height: 100%;
260
+ padding: 0;
261
+ border: none;
262
+ background: none;
263
+ color: inherit;
264
+ font: inherit;
265
+ }
266
+
267
+ .select-input:focus {
268
+ outline: none;
269
+ }
270
+
271
+ .select-input::placeholder {
272
+ color: var(--color-text-secondary);
273
+ }
274
+
275
+ .select-icon-button {
276
+ display: inline-flex;
277
+ align-items: center;
278
+ flex-shrink: 0;
279
+ padding: 0;
280
+ border: none;
281
+ background: none;
282
+ color: inherit;
283
+ cursor: pointer;
284
+ }
285
+
143
286
  .select-icon {
144
287
  display: flex;
145
288
  align-items: center;
@@ -160,10 +303,23 @@ const id = useId();
160
303
  max-height: var(--reka-select-content-available-height);
161
304
  }
162
305
 
306
+ /* Combobox exposes the anchor width under its own custom property. */
307
+ .select-content-autocomplete {
308
+ width: var(--reka-combobox-trigger-width);
309
+ max-height: var(--reka-combobox-content-available-height);
310
+ }
311
+
163
312
  .select-viewport {
164
313
  padding: 0.25em;
165
314
  }
166
315
 
316
+ .select-empty {
317
+ padding: 0.5em;
318
+ font-size: var(--font-size-sm);
319
+ color: var(--color-text-secondary);
320
+ text-align: center;
321
+ }
322
+
167
323
  .select-item {
168
324
  display: flex;
169
325
  align-items: center;
package/index.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.6.0",
2
+ "version": "0.6.2",
3
3
  "package": "@cfasim-ui/docs",
4
4
  "content": {
5
5
  "components": [
@@ -136,7 +136,15 @@
136
136
  "slug": "select-box",
137
137
  "docs": "components/SelectBox/SelectBox.md",
138
138
  "source": "components/SelectBox/SelectBox.vue",
139
- "keywords": []
139
+ "keywords": [
140
+ "select",
141
+ "dropdown",
142
+ "autocomplete",
143
+ "combobox",
144
+ "filter",
145
+ "search",
146
+ "typeahead"
147
+ ]
140
148
  },
141
149
  {
142
150
  "name": "SidebarLayout",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cfasim-ui/docs",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "description": "LLM-friendly component and chart documentation for cfasim-ui",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {