@cfasim-ui/docs 0.5.1 → 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.
- package/charts/BarChart/BarChart.md +3 -0
- package/charts/BarChart/BarChart.vue +342 -317
- package/charts/ChartMenu/ChartMenu.vue +75 -5
- package/charts/ChoroplethMap/ChoroplethMap.md +5 -0
- package/charts/ChoroplethMap/ChoroplethMap.vue +193 -88
- package/charts/LineChart/LineChart.md +21 -4
- package/charts/LineChart/LineChart.vue +398 -367
- package/charts/_shared/chartProps.ts +10 -0
- package/charts/_shared/useChartFoundation.ts +15 -0
- package/charts/_shared/useChartFullscreen.ts +66 -9
- package/charts/_shared/useChartMenu.ts +13 -6
- package/charts/_shared/useChartTooltip.ts +57 -4
- package/charts/index.ts +0 -2
- package/components/ButtonGroup/ButtonGroup.md +64 -0
- package/components/ButtonGroup/ButtonGroup.vue +91 -0
- package/components/MultiSelect/MultiSelect.md +143 -0
- package/components/MultiSelect/MultiSelect.vue +338 -0
- package/components/ParamEditor/ParamEditor.md +19 -0
- package/components/ParamEditor/ParamEditor.vue +4 -0
- package/components/ParamEditor/ParamEditorImpl.vue +3 -3
- package/components/ToggleGroup/ToggleGroup.md +163 -0
- package/components/ToggleGroup/ToggleGroup.vue +143 -0
- package/components/index.ts +5 -0
- package/index.json +47 -1
- package/package.json +1 -1
- package/charts/_shared/fullscreen.css +0 -51
|
@@ -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
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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" : "
|
|
121
|
+
label: isFullscreen.value ? "Collapse" : "Fullscreen",
|
|
99
122
|
action: toggle,
|
|
100
123
|
ariaPressed: isFullscreen.value,
|
|
101
124
|
}));
|
|
102
125
|
|
|
103
|
-
|
|
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
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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 (
|
|
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
|
|
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
|
|
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.
|
|
133
|
-
//
|
|
134
|
-
// touch
|
|
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
|
@@ -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
|
+
```
|