@cfasim-ui/docs 0.5.0 → 0.6.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.
@@ -148,6 +148,16 @@ export interface ChartCommonProps {
148
148
  * are set, `downloadButton` wins.
149
149
  */
150
150
  downloadButton?: boolean | string;
151
+ /**
152
+ * Where to teleport the chart while expanded (the Expand menu item). A
153
+ * CSS selector or element; defaults to `body`. The expanded chart is
154
+ * moved here so its `position: fixed` resolves against the viewport
155
+ * instead of being trapped by an ancestor's `transform`/`filter`/
156
+ * `contain`/`perspective` (which would otherwise become its containing
157
+ * block) or stacking context. Set this when your app doesn't mount at
158
+ * the document root (e.g. inside a shadow root or a dedicated overlay).
159
+ */
160
+ fullscreenTarget?: string | HTMLElement;
151
161
  /** Annotations rendered as the top layer of the chart. */
152
162
  annotations?: readonly ChartAnnotation[];
153
163
  /**
@@ -26,6 +26,7 @@ export interface ChartFoundationOptions {
26
26
  filename: () => string | undefined;
27
27
  downloadLink: () => boolean | string | undefined;
28
28
  downloadButton: () => boolean | string | undefined;
29
+ fullscreenTarget: () => string | HTMLElement | undefined;
29
30
  chartPadding: () => ChartPadding | undefined;
30
31
  // Chart-specific hooks that the composable can't infer.
31
32
  /**
@@ -38,6 +39,12 @@ export interface ChartFoundationOptions {
38
39
  getCsv: () => string;
39
40
  pointerToIndex: (clientX: number, clientY: number) => number | null;
40
41
  onHover: (payload: { index: number } | null) => void;
42
+ /**
43
+ * Axis a finger drags along to scrub the tooltip on touch; the
44
+ * orthogonal direction is left to the browser for page scrolling.
45
+ * Defaults to `"x"`.
46
+ */
47
+ scrubAxis?: () => "x" | "y";
41
48
  /**
42
49
  * Extra height (in px) the chart adds *below* the SVG plot area
43
50
  * (e.g. LineChart's area-section labels). Used to keep the SVG total
@@ -66,6 +73,9 @@ export function useChartFoundation(opts: ChartFoundationOptions) {
66
73
  triggerCsvDownload,
67
74
  resolvedFilename: menuFilename,
68
75
  isFullscreen,
76
+ fullscreenStyle,
77
+ teleportTarget,
78
+ exitFullscreen,
69
79
  } = useChartMenu({
70
80
  filename: opts.filename,
71
81
  legacyMenuLabel: opts.menu,
@@ -73,6 +83,7 @@ export function useChartFoundation(opts: ChartFoundationOptions) {
73
83
  downloadLink: opts.downloadLink,
74
84
  downloadButton: opts.downloadButton,
75
85
  fullscreen: true,
86
+ fullscreenTarget: opts.fullscreenTarget,
76
87
  });
77
88
 
78
89
  const width = computed(() => {
@@ -113,6 +124,7 @@ export function useChartFoundation(opts: ChartFoundationOptions) {
113
124
  clamp: () => opts.tooltipClamp() ?? "chart",
114
125
  pointerToIndex: opts.pointerToIndex,
115
126
  containerRef,
127
+ scrubAxis: opts.scrubAxis,
116
128
  onHover: opts.onHover,
117
129
  });
118
130
 
@@ -138,6 +150,9 @@ export function useChartFoundation(opts: ChartFoundationOptions) {
138
150
  triggerCsvDownload,
139
151
  menuFilename,
140
152
  isFullscreen,
153
+ fullscreenStyle,
154
+ teleportTarget,
155
+ exitFullscreen,
141
156
  measuredHeight,
142
157
  };
143
158
  }
@@ -27,15 +27,38 @@ function unlockBodyScroll() {
27
27
  }
28
28
  }
29
29
 
30
+ export interface ChartFullscreenOptions {
31
+ /**
32
+ * Reactive getter for where to teleport the expanded chart. A CSS
33
+ * selector or element; resolves to `"body"` when unset. Teleporting
34
+ * out to the document root is what makes `position: fixed` reliable:
35
+ * a `transform`/`filter`/`contain`/`perspective` on any ancestor would
36
+ * otherwise become the containing block and trap the "fullscreen" box
37
+ * inside it.
38
+ */
39
+ target?: () => string | HTMLElement | undefined;
40
+ }
41
+
30
42
  /**
31
- * Tracks whether the chart is in "expanded" mode (fills the window via
32
- * CSS). The browser Fullscreen API isn't used — the consumer wires a
33
- * `.is-fullscreen` class to its wrapper and the CSS handles positioning,
34
- * which avoids Fullscreen API restrictions (user-gesture requirement,
35
- * permission policy, iframe sandboxing) and keeps the chart inside the
36
- * document so the rest of the page remains keyboard-navigable.
43
+ * Tracks whether the chart is in "expanded" mode (fills the window). The
44
+ * browser Fullscreen API isn't used — avoiding its restrictions
45
+ * (user-gesture requirement, permission policy, iframe sandboxing) and
46
+ * keeping the chart inside the document so the rest of the page stays
47
+ * keyboard-navigable.
48
+ *
49
+ * Resilience is the whole point here, so the layout is driven two ways
50
+ * that don't depend on the CSS cascade:
51
+ * - `fullscreenStyle` returns the critical layout as an *inline* style
52
+ * object. Inline styles outrank any class rule regardless of stylesheet
53
+ * source order, so they beat each chart's scoped `position: relative`
54
+ * base rule (equal specificity, and the scoped rule often loads last)
55
+ * and still work even if the consumer never imported the package CSS.
56
+ * - `teleportTarget` moves the node to the document root so the fixed
57
+ * positioning resolves against the viewport, not a transformed ancestor.
58
+ * The `.is-fullscreen` class remains on the wrapper only as a hook for the
59
+ * one rule inline styles can't express (ChoroplethMap's SVG stretch).
37
60
  */
38
- export function useChartFullscreen() {
61
+ export function useChartFullscreen(opts: ChartFullscreenOptions = {}) {
39
62
  const isFullscreen = ref(false);
40
63
  let locked = false;
41
64
 
@@ -95,10 +118,44 @@ export function useChartFullscreen() {
95
118
  * that builds its menu list directly.
96
119
  */
97
120
  const menuItem = computed<ChartMenuItem>(() => ({
98
- label: isFullscreen.value ? "Collapse" : "Expand",
121
+ label: isFullscreen.value ? "Collapse" : "Fullscreen",
99
122
  action: toggle,
100
123
  ariaPressed: isFullscreen.value,
101
124
  }));
102
125
 
103
- return { isFullscreen, toggle, enter, exit, menuItem };
126
+ /**
127
+ * Critical fullscreen layout as inline styles (only while expanded).
128
+ * Bound via `:style` on the chart wrapper so it always wins over the
129
+ * scoped base rule and survives a missing stylesheet import.
130
+ */
131
+ const fullscreenStyle = computed<Record<string, string> | undefined>(() =>
132
+ isFullscreen.value
133
+ ? {
134
+ position: "fixed",
135
+ inset: "0",
136
+ "z-index": "var(--cfasim-z-fullscreen, 1000)",
137
+ background: "var(--color-bg-0, #fff)",
138
+ color: "var(--color-text, inherit)",
139
+ padding: "2em",
140
+ "box-sizing": "border-box",
141
+ display: "flex",
142
+ "flex-direction": "column",
143
+ "justify-content": "center",
144
+ }
145
+ : undefined,
146
+ );
147
+
148
+ const teleportTarget = computed<string | HTMLElement>(
149
+ () => opts.target?.() || "body",
150
+ );
151
+
152
+ return {
153
+ isFullscreen,
154
+ toggle,
155
+ enter,
156
+ exit,
157
+ menuItem,
158
+ fullscreenStyle,
159
+ teleportTarget,
160
+ };
104
161
  }
@@ -14,16 +14,18 @@ export interface ChartMenuOptions {
14
14
  /** Whether a separate download button is rendered (and the CSV menu item should be hidden). */
15
15
  downloadButton?: () => boolean | string | undefined;
16
16
  /**
17
- * When true, prepends an Expand/Collapse menu item that toggles the
18
- * chart into a full-window view. The consumer is responsible for
19
- * binding the returned `isFullscreen` ref to a CSS class on its
20
- * wrapper.
17
+ * When true, prepends a Fullscreen menu item that toggles the chart
18
+ * into a full-window view. The consumer binds the returned
19
+ * `isFullscreen` class plus `fullscreenStyle` to its wrapper and wraps
20
+ * it in a `<Teleport :to="teleportTarget" :disabled="!isFullscreen">`.
21
21
  */
22
22
  fullscreen?: boolean;
23
+ /** Forwarded to `useChartFullscreen` — see its `target` option. */
24
+ fullscreenTarget?: () => string | HTMLElement | undefined;
23
25
  }
24
26
 
25
27
  /**
26
- * Computes the standard chart menu items (Expand / SVG / PNG / CSV) plus
28
+ * Computes the standard chart menu items (Fullscreen / SVG / PNG / CSV) plus
27
29
  * the CSV-download-link state shared by every chart.
28
30
  */
29
31
  export function useChartMenu(opts: ChartMenuOptions) {
@@ -36,7 +38,9 @@ export function useChartMenu(opts: ChartMenuOptions) {
36
38
  return typeof menu === "string" ? menu : "chart";
37
39
  }
38
40
 
39
- const fullscreen = opts.fullscreen ? useChartFullscreen() : null;
41
+ const fullscreen = opts.fullscreen
42
+ ? useChartFullscreen({ target: opts.fullscreenTarget })
43
+ : null;
40
44
 
41
45
  const items = computed<ChartMenuItem[]>(() => {
42
46
  const fname = resolvedFilename();
@@ -100,5 +104,8 @@ export function useChartMenu(opts: ChartMenuOptions) {
100
104
  triggerCsvDownload,
101
105
  resolvedFilename,
102
106
  isFullscreen: fullscreen?.isFullscreen ?? ref(false),
107
+ fullscreenStyle: fullscreen?.fullscreenStyle ?? computed(() => undefined),
108
+ teleportTarget: fullscreen?.teleportTarget ?? computed(() => "body"),
109
+ exitFullscreen: fullscreen?.exit ?? (() => {}),
103
110
  };
104
111
  }
@@ -19,6 +19,13 @@ export interface ChartTooltipOptions {
19
19
  containerRef: Ref<HTMLElement | null>;
20
20
  /** Pointer-vertical offset applied for touch interactions. */
21
21
  touchYOffset?: number;
22
+ /**
23
+ * Axis a finger drags *along* to scrub the tooltip. On touch, a drag in
24
+ * the orthogonal direction is left to the browser so the page can still
25
+ * scroll. Defaults to `"x"` (horizontal scrub, vertical page scroll) —
26
+ * correct for time-series line charts and vertical bar charts.
27
+ */
28
+ scrubAxis?: () => "x" | "y";
22
29
  /**
23
30
  * Emit hover events. The first arg is `{ index }` while hovering and
24
31
  * `null` when leaving.
@@ -33,12 +40,21 @@ export interface ChartTooltipOptions {
33
40
  */
34
41
  export function useChartTooltip(opts: ChartTooltipOptions) {
35
42
  const TOUCH_Y_OFFSET = opts.touchYOffset ?? 50;
43
+ // Pixels a finger must travel before we commit a touch drag to either
44
+ // scrubbing the tooltip or scrolling the page.
45
+ const TOUCH_SLOP = 10;
36
46
  const hoverIndex = ref<number | null>(null);
37
47
  const isTouching = ref(false);
38
48
  const tooltipRef = ref<HTMLElement | null>(null);
39
49
  const pointer = ref<{ clientX: number; clientY: number } | null>(null);
40
50
  const tooltipPos = ref<{ left: number; top: number } | null>(null);
41
51
 
52
+ // Touch-gesture tracking (plain locals — they don't drive rendering).
53
+ // "pending" until the drag direction is known; then "scrub" (we own the
54
+ // gesture and move the tooltip) or "scroll" (the browser scrolls the page).
55
+ let touchStart: { x: number; y: number } | null = null;
56
+ let touchMode: "pending" | "scrub" | "scroll" = "pending";
57
+
42
58
  function pointerFromEvent(
43
59
  event: MouseEvent | TouchEvent,
44
60
  ): { clientX: number; clientY: number } | null {
@@ -110,13 +126,47 @@ export function useChartTooltip(opts: ChartTooltipOptions) {
110
126
 
111
127
  function onTouchStart(event: TouchEvent) {
112
128
  if (!opts.enabled()) return;
113
- event.preventDefault();
129
+ const pt = pointerFromEvent(event);
130
+ if (!pt) return;
131
+ // Don't preventDefault here: that would cancel the browser's scroll for
132
+ // the whole gesture before we know its direction. We decide on the
133
+ // first move instead. (`touch-action` on the overlay caps which
134
+ // direction the browser may still claim.)
135
+ touchStart = { x: pt.clientX, y: pt.clientY };
136
+ touchMode = "pending";
114
137
  isTouching.value = true;
115
138
  updateHover(event);
116
139
  }
117
140
 
118
141
  function onTouchMove(event: TouchEvent) {
119
142
  if (!opts.enabled()) return;
143
+ // The page owns this gesture — let it scroll, leave the tooltip hidden.
144
+ if (touchMode === "scroll") return;
145
+
146
+ if (touchMode === "pending") {
147
+ const pt = pointerFromEvent(event);
148
+ if (!pt || !touchStart) return;
149
+ const dx = Math.abs(pt.clientX - touchStart.x);
150
+ const dy = Math.abs(pt.clientY - touchStart.y);
151
+ if (dx + dy < TOUCH_SLOP) {
152
+ // Too small to classify — track under the finger, but don't yet
153
+ // preventDefault so the browser can still start a scroll.
154
+ updateHover(event);
155
+ return;
156
+ }
157
+ const scrubAxis = opts.scrubAxis?.() ?? "x";
158
+ const isScrub = scrubAxis === "x" ? dx >= dy : dy >= dx;
159
+ if (!isScrub) {
160
+ // Drag is across the scrub axis — hand the gesture to the browser.
161
+ touchMode = "scroll";
162
+ hoverIndex.value = null;
163
+ opts.onHover?.(null);
164
+ return;
165
+ }
166
+ touchMode = "scrub";
167
+ }
168
+
169
+ // Scrubbing: we own the gesture, so suppress the native scroll.
120
170
  event.preventDefault();
121
171
  updateHover(event);
122
172
  }
@@ -124,14 +174,17 @@ export function useChartTooltip(opts: ChartTooltipOptions) {
124
174
  function onTouchEnd() {
125
175
  if (!opts.enabled()) return;
126
176
  isTouching.value = false;
177
+ touchStart = null;
178
+ touchMode = "pending";
127
179
  hoverIndex.value = null;
128
180
  opts.onHover?.(null);
129
181
  }
130
182
 
131
183
  // Note: when binding via `v-on="handlers"`, Vue expects event names
132
- // *without* the `on` prefix. Touch events default to passive in some
133
- // contexts; consumers using touch overlays should still bind the
134
- // touch handlers individually with `.prevent`.
184
+ // *without* the `on` prefix. The touch handlers call `preventDefault`
185
+ // internally (only while scrubbing), so the overlay must NOT bind them
186
+ // passively, and should set `touch-action` to the scroll direction it
187
+ // wants to keep (e.g. `pan-y` when `scrubAxis` is `"x"`).
135
188
  const handlers = {
136
189
  mousemove: onMouseMove,
137
190
  mouseleave: onMouseLeave,
package/charts/index.ts CHANGED
@@ -1,5 +1,3 @@
1
- import "./_shared/fullscreen.css";
2
-
3
1
  export {
4
2
  default as LineChart,
5
3
  type LineChartData,
@@ -0,0 +1,64 @@
1
+ # ButtonGroup
2
+
3
+ A container that visually joins a set of [`Button`](./button)s (or any
4
+ elements) into a single connected unit. It only handles layout and styling —
5
+ each button keeps its own click handler and state. For a stateful "pick one (or
6
+ more)" control that looks like a button group, use
7
+ [`ToggleGroup`](./toggle-group) instead.
8
+
9
+ ## Examples
10
+
11
+ ### Joined buttons
12
+
13
+ <ComponentDemo>
14
+ <ButtonGroup aria-label="Document actions">
15
+ <Button variant="secondary">Cut</Button>
16
+ <Button variant="secondary">Copy</Button>
17
+ <Button variant="secondary">Paste</Button>
18
+ </ButtonGroup>
19
+
20
+ <template #code>
21
+
22
+ ```vue
23
+ <ButtonGroup aria-label="Document actions">
24
+ <Button variant="secondary">Cut</Button>
25
+ <Button variant="secondary">Copy</Button>
26
+ <Button variant="secondary">Paste</Button>
27
+ </ButtonGroup>
28
+ ```
29
+
30
+ </template>
31
+ </ComponentDemo>
32
+
33
+ ### Vertical
34
+
35
+ Set `orientation="vertical"` to stack the buttons. They stretch to a common
36
+ width so the dividers line up.
37
+
38
+ <ComponentDemo>
39
+ <ButtonGroup orientation="vertical" aria-label="Zoom">
40
+ <Button>Zoom in</Button>
41
+ <Button>Reset</Button>
42
+ <Button>Zoom out</Button>
43
+ </ButtonGroup>
44
+
45
+ <template #code>
46
+
47
+ ```vue
48
+ <ButtonGroup orientation="vertical" aria-label="Zoom">
49
+ <Button>Zoom in</Button>
50
+ <Button>Reset</Button>
51
+ <Button>Zoom out</Button>
52
+ </ButtonGroup>
53
+ ```
54
+
55
+ </template>
56
+ </ComponentDemo>
57
+
58
+ ## Props
59
+
60
+ | Prop | Type | Required | Default |
61
+ |------|------|----------|---------|
62
+ | `ariaLabel` | `string` | No | — |
63
+ | `orientation` | `"horizontal" \| "vertical"` | No | `"horizontal"` |
64
+
@@ -0,0 +1,91 @@
1
+ <script setup lang="ts">
2
+ withDefaults(
3
+ defineProps<{
4
+ ariaLabel?: string;
5
+ orientation?: "horizontal" | "vertical";
6
+ }>(),
7
+ { orientation: "horizontal" },
8
+ );
9
+ </script>
10
+
11
+ <template>
12
+ <div
13
+ class="button-group"
14
+ role="group"
15
+ :data-orientation="orientation"
16
+ :aria-label="ariaLabel"
17
+ >
18
+ <slot />
19
+ </div>
20
+ </template>
21
+
22
+ <style scoped>
23
+ .button-group {
24
+ display: inline-flex;
25
+ }
26
+
27
+ .button-group[data-orientation="vertical"] {
28
+ flex-direction: column;
29
+ }
30
+
31
+ /* Children butt directly against each other; only the outer corners stay
32
+ * rounded and all children stretch to a common size. */
33
+ .button-group :deep(> *) {
34
+ border-radius: 0;
35
+ align-self: stretch;
36
+ }
37
+
38
+ /* Pull each child onto its neighbour so a pair of adjacent borders collapses
39
+ * into a single shared 1px divider (instead of two borders + a gap). */
40
+ .button-group[data-orientation="horizontal"] :deep(> *:not(:first-child)) {
41
+ margin-left: -1px;
42
+ }
43
+
44
+ .button-group[data-orientation="vertical"] :deep(> *:not(:first-child)) {
45
+ margin-top: -1px;
46
+ }
47
+
48
+ /* Lift the active child so its full border / focus ring shows above the
49
+ * overlapping neighbour. */
50
+ .button-group :deep(> *:hover),
51
+ .button-group :deep(> *:focus-visible),
52
+ .button-group :deep(> *:focus) {
53
+ position: relative;
54
+ z-index: 1;
55
+ }
56
+
57
+ /* Bordered buttons share their border as the divider. The solid `Button`
58
+ * variant has no border, so give adjacent solid buttons a divider of their
59
+ * own (a contrast-derived hairline that reads on the button colour). */
60
+ .button-group[data-orientation="horizontal"]
61
+ :deep(.button[data-variant="primary"] + .button[data-variant="primary"]) {
62
+ border-left: 1px solid
63
+ color-mix(in srgb, var(--color-text-on-primary) 35%, transparent);
64
+ }
65
+
66
+ .button-group[data-orientation="vertical"]
67
+ :deep(.button[data-variant="primary"] + .button[data-variant="primary"]) {
68
+ border-top: 1px solid
69
+ color-mix(in srgb, var(--color-text-on-primary) 35%, transparent);
70
+ }
71
+
72
+ .button-group[data-orientation="horizontal"] :deep(> *:first-child) {
73
+ border-top-left-radius: 0.375em;
74
+ border-bottom-left-radius: 0.375em;
75
+ }
76
+
77
+ .button-group[data-orientation="horizontal"] :deep(> *:last-child) {
78
+ border-top-right-radius: 0.375em;
79
+ border-bottom-right-radius: 0.375em;
80
+ }
81
+
82
+ .button-group[data-orientation="vertical"] :deep(> *:first-child) {
83
+ border-top-left-radius: 0.375em;
84
+ border-top-right-radius: 0.375em;
85
+ }
86
+
87
+ .button-group[data-orientation="vertical"] :deep(> *:last-child) {
88
+ border-bottom-left-radius: 0.375em;
89
+ border-bottom-right-radius: 0.375em;
90
+ }
91
+ </style>
@@ -0,0 +1,143 @@
1
+ # MultiSelect
2
+
3
+ A multi-select combobox with autocomplete. Built on reka-ui's Combobox: type to
4
+ filter the options, click to add them, and selected values appear as removable
5
+ chips. Binds to a `string[]` via `v-model`.
6
+
7
+ ## Examples
8
+
9
+ <script setup>
10
+ import { ref } from 'vue'
11
+ const states = ref(['ca'])
12
+ const stationery = ref([])
13
+ </script>
14
+
15
+ <ComponentDemo>
16
+ <div style="width: 280px">
17
+ <MultiSelect
18
+ v-model="states"
19
+ label="States"
20
+ placeholder="Search states…"
21
+ :options="[
22
+ { value: 'ca', label: 'California' },
23
+ { value: 'ny', label: 'New York' },
24
+ { value: 'tx', label: 'Texas' },
25
+ { value: 'wa', label: 'Washington' },
26
+ { value: 'fl', label: 'Florida' },
27
+ ]"
28
+ />
29
+ </div>
30
+
31
+ <template #code>
32
+
33
+ ```vue
34
+ <script setup>
35
+ import { ref } from "vue";
36
+ const states = ref(["ca"]);
37
+ </script>
38
+
39
+ <MultiSelect
40
+ v-model="states"
41
+ label="States"
42
+ placeholder="Search states…"
43
+ :options="[
44
+ { value: 'ca', label: 'California' },
45
+ { value: 'ny', label: 'New York' },
46
+ { value: 'tx', label: 'Texas' },
47
+ { value: 'wa', label: 'Washington' },
48
+ { value: 'fl', label: 'Florida' },
49
+ ]"
50
+ />
51
+ ```
52
+
53
+ </template>
54
+ </ComponentDemo>
55
+
56
+ Type in the field to filter, click an option to add it, and use a chip's `✕`
57
+ button (or press Backspace in an empty input) to remove a selection.
58
+
59
+ ### Hidden label
60
+
61
+ Use `hide-label` to visually hide the label while keeping it available to screen
62
+ readers. Prefer this over `aria-label` whenever you have label text.
63
+
64
+ <ComponentDemo>
65
+ <div style="width: 280px">
66
+ <MultiSelect
67
+ v-model="stationery"
68
+ label="Supplies"
69
+ hide-label
70
+ placeholder="Add supplies…"
71
+ :options="[
72
+ { value: 'pen', label: 'Pen' },
73
+ { value: 'pencil', label: 'Pencil' },
74
+ { value: 'marker', label: 'Marker' },
75
+ { value: 'eraser', label: 'Eraser' },
76
+ ]"
77
+ />
78
+ </div>
79
+
80
+ <template #code>
81
+
82
+ ```vue
83
+ <MultiSelect
84
+ v-model="supplies"
85
+ label="Supplies"
86
+ hide-label
87
+ placeholder="Add supplies…"
88
+ :options="[
89
+ { value: 'pen', label: 'Pen' },
90
+ { value: 'pencil', label: 'Pencil' },
91
+ { value: 'marker', label: 'Marker' },
92
+ { value: 'eraser', label: 'Eraser' },
93
+ ]"
94
+ />
95
+ ```
96
+
97
+ </template>
98
+ </ComponentDemo>
99
+
100
+ ## Accessibility
101
+
102
+ MultiSelect is built on reka-ui's Combobox, which implements the
103
+ [ARIA combobox pattern](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/):
104
+
105
+ - The text field has `role="combobox"` with `aria-expanded`, `aria-controls`,
106
+ `aria-autocomplete="list"`, and `aria-activedescendant` tracking the
107
+ highlighted option as you arrow through the list.
108
+ - The popup is a `role="listbox"` with `aria-multiselectable="true"`; each option
109
+ is a `role="option"` and reflects its state via `aria-selected`.
110
+ - Keyboard support is handled for you: type to filter, ↑/↓ to move, Enter to
111
+ toggle the highlighted option, Escape to close, and Backspace in an empty input
112
+ removes the last chip.
113
+ - Pass `label` so the field is named by a real `<label>` (associated via
114
+ `aria-labelledby`); use `hide-label` to keep it for screen readers while hiding
115
+ it visually, or `aria-label` when there's no visible label text.
116
+ - Each chip's remove button has an `aria-label` (e.g. "Remove California").
117
+
118
+ ## Model
119
+
120
+ | Name | Type |
121
+ |------|------|
122
+ | `v-model` | `string[]` |
123
+
124
+ ## Props
125
+
126
+ | Prop | Type | Required | Default |
127
+ |------|------|----------|---------|
128
+ | `label` | `string` | No | — |
129
+ | `hideLabel` | `boolean` | No | — |
130
+ | `ariaLabel` | `string` | No | — |
131
+ | `options` | `MultiSelectOption[]` | Yes | — |
132
+ | `placeholder` | `string` | No | — |
133
+ | `hint` | `string` | No | — |
134
+
135
+
136
+ ### MultiSelectOption
137
+
138
+ ```ts
139
+ interface MultiSelectOption {
140
+ value: string;
141
+ label: string;
142
+ }
143
+ ```