@robr0/design-system 0.12.0 → 0.13.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/README.md +2 -2
- package/components/AvatarGroup/AvatarGroup.css +61 -0
- package/components/AvatarGroup/AvatarGroup.d.ts +32 -0
- package/components/AvatarGroup/AvatarGroup.js +31 -0
- package/components/FilterBar/FilterBar.css +202 -0
- package/components/FilterBar/FilterBar.d.ts +55 -0
- package/components/FilterBar/FilterBar.js +249 -0
- package/components/Gauge/Gauge.css +93 -0
- package/components/Gauge/Gauge.d.ts +56 -0
- package/components/Gauge/Gauge.js +95 -0
- package/components/SplitPane/SplitPane.css +106 -0
- package/components/SplitPane/SplitPane.d.ts +36 -0
- package/components/SplitPane/SplitPane.js +130 -0
- package/components/StreamingText/StreamingText.css +40 -0
- package/components/StreamingText/StreamingText.d.ts +44 -0
- package/components/StreamingText/StreamingText.js +61 -0
- package/components/registry.json +40 -0
- package/components/registry.json.d.ts +40 -0
- package/components/registry.json.js +1 -1
- package/index.d.ts +5 -0
- package/index.js +10 -0
- package/package.json +1 -1
- package/tokens/motion.d.ts +7 -3
- package/tokens/motion.js +3 -1
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@ The **live site** is the portfolio built on the design system. **Storybook** is
|
|
|
18
18
|
| Part | Description |
|
|
19
19
|
|---|---|
|
|
20
20
|
| **Portfolio website** (`/website`) | Next.js app with case studies, work history, writing, and about pages, all built exclusively with the design system components below. **[Live site →](https://robertritacca.com/)** |
|
|
21
|
-
| **Design system** (`/src`) | <!-- component-count -->
|
|
21
|
+
| **Design system** (`/src`) | <!-- component-count -->114<!-- /component-count --> React components, a three-tier token architecture, dark mode, a WebGL2 ambient background that themes itself from your tokens, and a full documentation site. Built to production standards. **[Storybook →](https://design-system-iota-one.vercel.app/?path=/docs/robr0-ds--docs)** |
|
|
22
22
|
| **AI layer** (`ai` components in `/src`, chat in `/website`, `/evals`) | A site-wide chat that answers questions about the work: built from the library's own `ai` components, grounded in a corpus generated from the site's published content, and scored by a golden-set eval. |
|
|
23
23
|
|
|
24
24
|
---
|
|
@@ -28,7 +28,7 @@ The **live site** is the portfolio built on the design system. **Storybook** is
|
|
|
28
28
|
### Components
|
|
29
29
|
|
|
30
30
|
<!-- component-list:start -->
|
|
31
|
-
Accordion · Agent plan · Agent status · AI button · Alert · Alert dialog · Anchor nav · App layout · App sidebar · Area chart · Avatar · Badge · Bar chart · Breadcrumb · Button · Button group · Card · Card stack · Carousel · Chat header · Chat marker · Chat message · Chat thread · Checkbox · Chip · Circular button · Code block · Code diff · Colour picker · Combo chart · Combobox · Command palette · Composer · Contact card · Context menu · Contribution graph · Data table · Date input · Date picker · Dialog · Divider · Document chip · Drawer · Dropdown · Dropdown menu · Empty state · Entity card · Event calendar · Field · Figure · File input · Funnel chart · Globe · Input · Instructions · Interrupt card · Kbd · Legend tile · Line chart · Link list · Map callout · Map legend · Message actions · Message card · Model picker · Nav · Nav list · Notification centre · Number input · Pagination · Panel · Pie chart · Pin input · Popover · Progress bar · Prompt suggestions · Prose · Quote · Radar chart · Radial chart · Radio button · Reasoning · Scatter chart · Section title · Segmented control · Selection card · Shader field · Skeleton · Slider · Source chip · Sparkline · Spinner · Stacked bar chart · Stat · Stepper · Swatch · Table · Tabs · Tag input · Textarea · Time picker · Timeline · Toast · Toggle group · Toggle switch · Tool call · Tooltip · Tree view · Treemap
|
|
31
|
+
Accordion · Agent plan · Agent status · AI button · Alert · Alert dialog · Anchor nav · App layout · App sidebar · Area chart · Avatar · Avatar group · Badge · Bar chart · Breadcrumb · Button · Button group · Card · Card stack · Carousel · Chat header · Chat marker · Chat message · Chat thread · Checkbox · Chip · Circular button · Code block · Code diff · Colour picker · Combo chart · Combobox · Command palette · Composer · Contact card · Context menu · Contribution graph · Data table · Date input · Date picker · Dialog · Divider · Document chip · Drawer · Dropdown · Dropdown menu · Empty state · Entity card · Event calendar · Field · Figure · File input · Filter bar · Funnel chart · Gauge · Globe · Input · Instructions · Interrupt card · Kbd · Legend tile · Line chart · Link list · Map callout · Map legend · Message actions · Message card · Model picker · Nav · Nav list · Notification centre · Number input · Pagination · Panel · Pie chart · Pin input · Popover · Progress bar · Prompt suggestions · Prose · Quote · Radar chart · Radial chart · Radio button · Reasoning · Scatter chart · Section title · Segmented control · Selection card · Shader field · Skeleton · Slider · Source chip · Sparkline · Spinner · Split pane · Stacked bar chart · Stat · Stepper · Streaming text · Swatch · Table · Tabs · Tag input · Textarea · Time picker · Timeline · Toast · Toggle group · Toggle switch · Tool call · Tooltip · Tree view · Treemap
|
|
32
32
|
<!-- component-list:end -->
|
|
33
33
|
|
|
34
34
|
### Ambient background
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/* ============================================
|
|
2
|
+
AVATAR GROUP COMPONENT
|
|
3
|
+
Overlapping avatar stack with a "+N" counter.
|
|
4
|
+
The pixel size ladder matches Avatar's sizes
|
|
5
|
+
so the counter circle sits flush in the row.
|
|
6
|
+
============================================ */
|
|
7
|
+
|
|
8
|
+
/* Base */
|
|
9
|
+
|
|
10
|
+
.ds-avatar-group {
|
|
11
|
+
display: inline-flex;
|
|
12
|
+
align-items: center;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/* Items — each avatar (and the counter) sits in a ring-bearing wrapper
|
|
16
|
+
so the overlaps stay legible on any surface. */
|
|
17
|
+
|
|
18
|
+
.ds-avatar-group__item {
|
|
19
|
+
position: relative;
|
|
20
|
+
display: inline-flex;
|
|
21
|
+
border-radius: var(--radius-full);
|
|
22
|
+
box-shadow: 0 0 0 var(--border-md) var(--color-bg-page-primary);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.ds-avatar-group__item + .ds-avatar-group__item {
|
|
26
|
+
margin-left: calc(var(--gap-sm) * -1);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/* Overflow counter */
|
|
30
|
+
|
|
31
|
+
.ds-avatar-group__overflow {
|
|
32
|
+
align-items: center;
|
|
33
|
+
justify-content: center;
|
|
34
|
+
flex-shrink: 0;
|
|
35
|
+
background-color: var(--color-bg-container-secondary);
|
|
36
|
+
color: var(--color-text-secondary);
|
|
37
|
+
font-family: var(--font-paragraph-em-family);
|
|
38
|
+
font-weight: var(--font-paragraph-em-weight);
|
|
39
|
+
line-height: 1;
|
|
40
|
+
user-select: none;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/* Sizes — matching Avatar's ladder */
|
|
44
|
+
|
|
45
|
+
.ds-avatar-group--sm .ds-avatar-group__overflow {
|
|
46
|
+
width: 32px;
|
|
47
|
+
height: 32px;
|
|
48
|
+
font-size: 12px;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.ds-avatar-group--md .ds-avatar-group__overflow {
|
|
52
|
+
width: 40px;
|
|
53
|
+
height: 40px;
|
|
54
|
+
font-size: 14px;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.ds-avatar-group--lg .ds-avatar-group__overflow {
|
|
58
|
+
width: 56px;
|
|
59
|
+
height: 56px;
|
|
60
|
+
font-size: 18px;
|
|
61
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { default as React } from 'react';
|
|
2
|
+
/** Props owned by AvatarGroup itself — everything else falls through to the root div. */
|
|
3
|
+
type AvatarGroupOwnProps = {
|
|
4
|
+
/** The avatars to stack, in display order — normally `Avatar` elements. */
|
|
5
|
+
children: React.ReactNode;
|
|
6
|
+
/** Avatars shown before the rest collapse into a "+N" counter. */
|
|
7
|
+
max?: number;
|
|
8
|
+
/**
|
|
9
|
+
* Size applied to every avatar in the stack and to the overflow counter.
|
|
10
|
+
* Cloned onto the children so the group cannot render mixed sizes.
|
|
11
|
+
*/
|
|
12
|
+
size?: 'sm' | 'md' | 'lg';
|
|
13
|
+
/**
|
|
14
|
+
* Accessible label for the overflow counter. Defaults to "N more" —
|
|
15
|
+
* override it to localise or add context, e.g. "4 more reviewers".
|
|
16
|
+
*/
|
|
17
|
+
overflowLabel?: string;
|
|
18
|
+
/** Additional CSS classes */
|
|
19
|
+
className?: string;
|
|
20
|
+
};
|
|
21
|
+
export interface AvatarGroupProps extends AvatarGroupOwnProps, Omit<React.ComponentPropsWithoutRef<'div'>, keyof AvatarGroupOwnProps> {
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* AvatarGroup — an overlapping stack of avatars with a "+N" counter for the
|
|
25
|
+
* overflow, the "who's here" affordance in collaboration UIs. Purely
|
|
26
|
+
* presentational: it lays out the `Avatar` children it is given (forcing a
|
|
27
|
+
* uniform size onto them) and counts the rest, so it renders from a Server
|
|
28
|
+
* Component. Each avatar carries a page-coloured ring so the overlaps stay
|
|
29
|
+
* legible on any surface.
|
|
30
|
+
*/
|
|
31
|
+
export declare const AvatarGroup: React.ForwardRefExoticComponent<AvatarGroupProps & React.RefAttributes<HTMLDivElement>>;
|
|
32
|
+
export {};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { jsxs, jsx } from "react/jsx-runtime";
|
|
2
|
+
import React from "react";
|
|
3
|
+
import "./AvatarGroup.css";
|
|
4
|
+
const AvatarGroup = React.forwardRef(
|
|
5
|
+
({ children, max = 5, size = "md", overflowLabel, className = "", ...rest }, ref) => {
|
|
6
|
+
const baseClass = "ds-avatar-group";
|
|
7
|
+
const classes = [baseClass, `${baseClass}--${size}`, className].filter(Boolean).join(" ");
|
|
8
|
+
const allChildren = React.Children.toArray(children);
|
|
9
|
+
const visible = allChildren.slice(0, Math.max(max, 0));
|
|
10
|
+
const overflow = allChildren.length - visible.length;
|
|
11
|
+
return /* @__PURE__ */ jsxs("div", { ...rest, ref, className: classes, role: "group", children: [
|
|
12
|
+
visible.map((child, index) => /* @__PURE__ */ jsx("span", { className: `${baseClass}__item`, children: React.isValidElement(child) ? React.cloneElement(child, { size }) : child }, index)),
|
|
13
|
+
overflow > 0 && /* @__PURE__ */ jsx(
|
|
14
|
+
"span",
|
|
15
|
+
{
|
|
16
|
+
className: `${baseClass}__item ${baseClass}__overflow`,
|
|
17
|
+
role: "img",
|
|
18
|
+
"aria-label": overflowLabel ?? `${overflow} more`,
|
|
19
|
+
children: /* @__PURE__ */ jsxs("span", { "aria-hidden": "true", children: [
|
|
20
|
+
"+",
|
|
21
|
+
overflow
|
|
22
|
+
] })
|
|
23
|
+
}
|
|
24
|
+
)
|
|
25
|
+
] });
|
|
26
|
+
}
|
|
27
|
+
);
|
|
28
|
+
AvatarGroup.displayName = "AvatarGroup";
|
|
29
|
+
export {
|
|
30
|
+
AvatarGroup
|
|
31
|
+
};
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/* ============================================
|
|
2
|
+
FILTER BAR COMPONENT
|
|
3
|
+
A row of filter chips, each opening a
|
|
4
|
+
popover listbox. The chips share Chip's pill
|
|
5
|
+
language; active state uses the teal fill
|
|
6
|
+
convention from Chip's selected state.
|
|
7
|
+
============================================ */
|
|
8
|
+
|
|
9
|
+
/* Base */
|
|
10
|
+
|
|
11
|
+
.ds-filter-bar {
|
|
12
|
+
display: flex;
|
|
13
|
+
align-items: center;
|
|
14
|
+
flex-wrap: wrap;
|
|
15
|
+
gap: var(--gap-sm);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.ds-filter-bar__filter {
|
|
19
|
+
position: relative;
|
|
20
|
+
display: inline-flex;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/* Chip shell — the pill holding the trigger and the clear button */
|
|
24
|
+
|
|
25
|
+
.ds-filter-bar__chip {
|
|
26
|
+
display: inline-flex;
|
|
27
|
+
align-items: center;
|
|
28
|
+
gap: var(--gap-sm);
|
|
29
|
+
padding: var(--padding-xs) var(--padding-sm-md);
|
|
30
|
+
border: var(--border-xs) solid var(--color-bg-container-border);
|
|
31
|
+
border-radius: var(--radius-full);
|
|
32
|
+
background-color: var(--color-bg-page-primary);
|
|
33
|
+
font-family: var(--font-paragraph-sm-em-family);
|
|
34
|
+
font-size: var(--font-paragraph-sm-em-size);
|
|
35
|
+
font-weight: var(--font-paragraph-sm-em-weight);
|
|
36
|
+
line-height: var(--font-paragraph-sm-em-line-height);
|
|
37
|
+
letter-spacing: var(--font-paragraph-sm-em-letter-spacing);
|
|
38
|
+
color: var(--color-text-secondary);
|
|
39
|
+
white-space: nowrap;
|
|
40
|
+
transition:
|
|
41
|
+
background-color var(--motion-duration-base) var(--motion-ease-standard),
|
|
42
|
+
border-color var(--motion-duration-base) var(--motion-ease-standard),
|
|
43
|
+
color var(--motion-duration-base) var(--motion-ease-standard);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.ds-filter-bar--compact .ds-filter-bar__chip {
|
|
47
|
+
gap: var(--gap-xs);
|
|
48
|
+
padding: var(--padding-xxxs) var(--padding-sm);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.ds-filter-bar__chip:hover:not(.ds-filter-bar__chip--active) {
|
|
52
|
+
background-color: var(--color-action-passive-bg-hover);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.ds-filter-bar__chip--open:not(.ds-filter-bar__chip--active) {
|
|
56
|
+
background-color: var(--color-action-passive-bg-active);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/* Active — teal fill, matching Chip's selected convention */
|
|
60
|
+
|
|
61
|
+
.ds-filter-bar__chip--active {
|
|
62
|
+
background-color: var(--color-action-primary-bg);
|
|
63
|
+
border-color: var(--color-action-primary-bg);
|
|
64
|
+
color: var(--color-action-primary-text);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.ds-filter-bar__chip--active:hover {
|
|
68
|
+
background-color: var(--color-action-primary-bg-hover);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/* Trigger + clear share the shell's typography and colour */
|
|
72
|
+
|
|
73
|
+
.ds-filter-bar__trigger,
|
|
74
|
+
.ds-filter-bar__chip-clear {
|
|
75
|
+
display: inline-flex;
|
|
76
|
+
align-items: center;
|
|
77
|
+
gap: inherit;
|
|
78
|
+
margin: 0;
|
|
79
|
+
padding: 0;
|
|
80
|
+
border: none;
|
|
81
|
+
background: none;
|
|
82
|
+
font: inherit;
|
|
83
|
+
letter-spacing: inherit;
|
|
84
|
+
color: inherit;
|
|
85
|
+
cursor: pointer;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.ds-filter-bar__trigger:focus-visible,
|
|
89
|
+
.ds-filter-bar__chip-clear:focus-visible,
|
|
90
|
+
.ds-filter-bar__clear-all:focus-visible {
|
|
91
|
+
outline: var(--border-md) solid var(--color-action-primary-bg);
|
|
92
|
+
outline-offset: var(--gap-xxs);
|
|
93
|
+
border-radius: var(--radius-full);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
.ds-filter-bar__icon {
|
|
97
|
+
flex-shrink: 0;
|
|
98
|
+
--icon-size: var(--icon-size-sm);
|
|
99
|
+
line-height: 1;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.ds-filter-bar__caret {
|
|
103
|
+
flex-shrink: 0;
|
|
104
|
+
--icon-size: var(--icon-size-sm);
|
|
105
|
+
line-height: 1;
|
|
106
|
+
transition: transform var(--motion-duration-base) var(--motion-ease-standard);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.ds-filter-bar__chip--open .ds-filter-bar__caret {
|
|
110
|
+
transform: rotate(180deg);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.ds-filter-bar__chip-clear {
|
|
114
|
+
flex-shrink: 0;
|
|
115
|
+
border-radius: var(--radius-full);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.ds-filter-bar__chip-clear .material-symbols-rounded {
|
|
119
|
+
--icon-size: var(--icon-size-sm);
|
|
120
|
+
line-height: 1;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/* Popover listbox */
|
|
124
|
+
|
|
125
|
+
.ds-filter-bar__panel {
|
|
126
|
+
position: absolute;
|
|
127
|
+
top: calc(100% + var(--gap-xxs));
|
|
128
|
+
left: 0;
|
|
129
|
+
z-index: 10;
|
|
130
|
+
min-width: 100%;
|
|
131
|
+
max-height: 280px;
|
|
132
|
+
overflow-y: auto;
|
|
133
|
+
margin: 0;
|
|
134
|
+
padding: var(--padding-xxs);
|
|
135
|
+
list-style: none;
|
|
136
|
+
border: var(--border-xs) solid var(--color-bg-container-border);
|
|
137
|
+
border-radius: var(--radius-md);
|
|
138
|
+
background-color: var(--color-bg-container-primary);
|
|
139
|
+
box-shadow: var(--shadow-floating);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.ds-filter-bar__option {
|
|
143
|
+
display: flex;
|
|
144
|
+
align-items: center;
|
|
145
|
+
gap: var(--gap-xs);
|
|
146
|
+
padding: var(--padding-xs) var(--padding-sm);
|
|
147
|
+
border-radius: var(--radius-sm);
|
|
148
|
+
font-family: var(--font-paragraph-sm-family);
|
|
149
|
+
font-size: var(--font-paragraph-sm-size);
|
|
150
|
+
font-weight: var(--font-paragraph-sm-weight);
|
|
151
|
+
line-height: var(--font-paragraph-sm-line-height);
|
|
152
|
+
letter-spacing: var(--font-paragraph-sm-letter-spacing);
|
|
153
|
+
color: var(--color-text-primary);
|
|
154
|
+
white-space: nowrap;
|
|
155
|
+
cursor: pointer;
|
|
156
|
+
transition: background-color var(--motion-duration-fast) var(--motion-ease-standard);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
.ds-filter-bar__option:hover,
|
|
160
|
+
.ds-filter-bar__option--focused {
|
|
161
|
+
background-color: var(--color-action-passive-bg-hover);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
.ds-filter-bar__option--selected {
|
|
165
|
+
color: var(--color-text-primary);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
.ds-filter-bar__option-check {
|
|
169
|
+
flex-shrink: 0;
|
|
170
|
+
width: var(--icon-size-sm);
|
|
171
|
+
--icon-size: var(--icon-size-sm);
|
|
172
|
+
line-height: 1;
|
|
173
|
+
color: var(--color-action-primary-bg);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/* Clear all */
|
|
177
|
+
|
|
178
|
+
.ds-filter-bar__clear-all {
|
|
179
|
+
margin: 0;
|
|
180
|
+
padding: var(--padding-xs) var(--padding-sm);
|
|
181
|
+
border: none;
|
|
182
|
+
background: none;
|
|
183
|
+
border-radius: var(--radius-full);
|
|
184
|
+
font-family: var(--font-paragraph-sm-em-family);
|
|
185
|
+
font-size: var(--font-paragraph-sm-em-size);
|
|
186
|
+
font-weight: var(--font-paragraph-sm-em-weight);
|
|
187
|
+
line-height: var(--font-paragraph-sm-em-line-height);
|
|
188
|
+
letter-spacing: var(--font-paragraph-sm-em-letter-spacing);
|
|
189
|
+
color: var(--color-text-secondary);
|
|
190
|
+
cursor: pointer;
|
|
191
|
+
transition: color var(--motion-duration-base) var(--motion-ease-standard);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
.ds-filter-bar__clear-all:hover {
|
|
195
|
+
color: var(--color-text-primary);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/* Icons take the colour of the text around them (see Chip.css for why
|
|
199
|
+
this is explicit rather than inherited). */
|
|
200
|
+
.ds-filter-bar .material-symbols-rounded {
|
|
201
|
+
color: inherit;
|
|
202
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { default as React } from 'react';
|
|
2
|
+
/** One choice inside a filter's popover. */
|
|
3
|
+
export interface FilterBarOption {
|
|
4
|
+
/** Stored value, reported through `onValuesChange`. */
|
|
5
|
+
value: string;
|
|
6
|
+
/** Display label. */
|
|
7
|
+
label: string;
|
|
8
|
+
}
|
|
9
|
+
/** One filter: a chip on the bar with a popover of options behind it. */
|
|
10
|
+
export interface FilterBarFilter {
|
|
11
|
+
/** Stable key for this filter — the key under which its values are reported. */
|
|
12
|
+
id: string;
|
|
13
|
+
/** Chip label, e.g. "Status". */
|
|
14
|
+
label: string;
|
|
15
|
+
/** Material Symbol icon name shown before the label. */
|
|
16
|
+
icon?: string;
|
|
17
|
+
/** The choices this filter offers. */
|
|
18
|
+
options: FilterBarOption[];
|
|
19
|
+
/** Whether several options can be active at once. Single-select closes on choice. */
|
|
20
|
+
multiple?: boolean;
|
|
21
|
+
}
|
|
22
|
+
/** Props owned by FilterBar itself — everything else falls through to the root div. */
|
|
23
|
+
type FilterBarOwnProps = {
|
|
24
|
+
/** The filters on the bar, in display order. */
|
|
25
|
+
filters: FilterBarFilter[];
|
|
26
|
+
/** Active options per filter id (controlled). Pair with `onValuesChange`. */
|
|
27
|
+
values?: Record<string, string[]>;
|
|
28
|
+
/** Active options per filter id (uncontrolled initial state). */
|
|
29
|
+
defaultValues?: Record<string, string[]>;
|
|
30
|
+
/**
|
|
31
|
+
* Fires with the full active-filter map on every change. Filters with
|
|
32
|
+
* nothing active are absent from the map, so an empty object means
|
|
33
|
+
* unfiltered.
|
|
34
|
+
*/
|
|
35
|
+
onValuesChange?: (values: Record<string, string[]>) => void;
|
|
36
|
+
/** Label for the button that clears every filter at once. */
|
|
37
|
+
clearLabel?: string;
|
|
38
|
+
/** Component size */
|
|
39
|
+
size?: 'default' | 'compact';
|
|
40
|
+
/** Additional CSS classes */
|
|
41
|
+
className?: string;
|
|
42
|
+
};
|
|
43
|
+
export interface FilterBarProps extends FilterBarOwnProps, Omit<React.ComponentPropsWithoutRef<'div'>, keyof FilterBarOwnProps | 'children'> {
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* FilterBar — a row of filter chips for narrowing a collection: each chip
|
|
47
|
+
* opens a popover of options, active filters show their choice and grow a
|
|
48
|
+
* clear button, and a clear-all appears once anything is active. State is one
|
|
49
|
+
* map of filter id to active values, controlled or uncontrolled, so wiring it
|
|
50
|
+
* to a DataTable is a single callback. Popovers are real listboxes: arrow
|
|
51
|
+
* keys move, Enter and Space toggle, Escape closes, focus never leaves the
|
|
52
|
+
* chip.
|
|
53
|
+
*/
|
|
54
|
+
export declare const FilterBar: React.ForwardRefExoticComponent<FilterBarProps & React.RefAttributes<HTMLDivElement>>;
|
|
55
|
+
export {};
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsxs, jsx } from "react/jsx-runtime";
|
|
3
|
+
import React, { useId, useState, useRef, useCallback, useEffect } from "react";
|
|
4
|
+
import "./FilterBar.css";
|
|
5
|
+
import "../../fonts/material-symbols.css";
|
|
6
|
+
const FilterBar = React.forwardRef(
|
|
7
|
+
({
|
|
8
|
+
filters,
|
|
9
|
+
values,
|
|
10
|
+
defaultValues,
|
|
11
|
+
onValuesChange,
|
|
12
|
+
clearLabel = "Clear all",
|
|
13
|
+
size = "default",
|
|
14
|
+
className = "",
|
|
15
|
+
...rest
|
|
16
|
+
}, ref) => {
|
|
17
|
+
const baseClass = "ds-filter-bar";
|
|
18
|
+
const generatedId = useId();
|
|
19
|
+
const [uncontrolledValues, setUncontrolledValues] = useState(
|
|
20
|
+
defaultValues ?? {}
|
|
21
|
+
);
|
|
22
|
+
const [openId, setOpenId] = useState(null);
|
|
23
|
+
const [focusedIndex, setFocusedIndex] = useState(-1);
|
|
24
|
+
const filterRefs = useRef(/* @__PURE__ */ new Map());
|
|
25
|
+
const activeValues = values ?? uncontrolledValues;
|
|
26
|
+
const applyValues = (next) => {
|
|
27
|
+
if (values === void 0) setUncontrolledValues(next);
|
|
28
|
+
onValuesChange?.(next);
|
|
29
|
+
};
|
|
30
|
+
const closePopover = useCallback(() => {
|
|
31
|
+
setOpenId(null);
|
|
32
|
+
setFocusedIndex(-1);
|
|
33
|
+
}, []);
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
if (!openId) return;
|
|
36
|
+
const handler = (e) => {
|
|
37
|
+
const node = filterRefs.current.get(openId);
|
|
38
|
+
if (node && !node.contains(e.target)) closePopover();
|
|
39
|
+
};
|
|
40
|
+
document.addEventListener("mousedown", handler);
|
|
41
|
+
return () => document.removeEventListener("mousedown", handler);
|
|
42
|
+
}, [openId, closePopover]);
|
|
43
|
+
const toggleOption = (filter, optionValue) => {
|
|
44
|
+
const current = activeValues[filter.id] ?? [];
|
|
45
|
+
let nextForFilter;
|
|
46
|
+
if (filter.multiple === false) {
|
|
47
|
+
nextForFilter = current[0] === optionValue ? [] : [optionValue];
|
|
48
|
+
} else {
|
|
49
|
+
nextForFilter = current.includes(optionValue) ? current.filter((v) => v !== optionValue) : [...current, optionValue];
|
|
50
|
+
}
|
|
51
|
+
const next = { ...activeValues };
|
|
52
|
+
if (nextForFilter.length > 0) next[filter.id] = nextForFilter;
|
|
53
|
+
else delete next[filter.id];
|
|
54
|
+
applyValues(next);
|
|
55
|
+
if (filter.multiple === false) closePopover();
|
|
56
|
+
};
|
|
57
|
+
const clearFilter = (filterId) => {
|
|
58
|
+
const next = { ...activeValues };
|
|
59
|
+
delete next[filterId];
|
|
60
|
+
applyValues(next);
|
|
61
|
+
if (openId === filterId) closePopover();
|
|
62
|
+
};
|
|
63
|
+
const handleTriggerClick = (filter) => {
|
|
64
|
+
setFocusedIndex(-1);
|
|
65
|
+
setOpenId((current) => current === filter.id ? null : filter.id);
|
|
66
|
+
};
|
|
67
|
+
const handleTriggerKeyDown = (e, filter) => {
|
|
68
|
+
const isOpen = openId === filter.id;
|
|
69
|
+
switch (e.key) {
|
|
70
|
+
case "Enter":
|
|
71
|
+
case " ":
|
|
72
|
+
e.preventDefault();
|
|
73
|
+
if (!isOpen) {
|
|
74
|
+
setOpenId(filter.id);
|
|
75
|
+
setFocusedIndex(0);
|
|
76
|
+
} else if (focusedIndex >= 0) {
|
|
77
|
+
toggleOption(filter, filter.options[focusedIndex].value);
|
|
78
|
+
} else {
|
|
79
|
+
closePopover();
|
|
80
|
+
}
|
|
81
|
+
break;
|
|
82
|
+
case "ArrowDown":
|
|
83
|
+
e.preventDefault();
|
|
84
|
+
if (!isOpen) {
|
|
85
|
+
setOpenId(filter.id);
|
|
86
|
+
setFocusedIndex(0);
|
|
87
|
+
} else {
|
|
88
|
+
setFocusedIndex((prev) => Math.min(prev + 1, filter.options.length - 1));
|
|
89
|
+
}
|
|
90
|
+
break;
|
|
91
|
+
case "ArrowUp":
|
|
92
|
+
e.preventDefault();
|
|
93
|
+
if (isOpen) setFocusedIndex((prev) => Math.max(prev - 1, 0));
|
|
94
|
+
break;
|
|
95
|
+
case "Home":
|
|
96
|
+
if (isOpen) {
|
|
97
|
+
e.preventDefault();
|
|
98
|
+
setFocusedIndex(0);
|
|
99
|
+
}
|
|
100
|
+
break;
|
|
101
|
+
case "End":
|
|
102
|
+
if (isOpen) {
|
|
103
|
+
e.preventDefault();
|
|
104
|
+
setFocusedIndex(filter.options.length - 1);
|
|
105
|
+
}
|
|
106
|
+
break;
|
|
107
|
+
case "Escape":
|
|
108
|
+
case "Tab":
|
|
109
|
+
closePopover();
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
const summarize = (filter) => {
|
|
114
|
+
const active = activeValues[filter.id] ?? [];
|
|
115
|
+
if (active.length === 0) return filter.label;
|
|
116
|
+
if (active.length === 1) {
|
|
117
|
+
const option = filter.options.find((o) => o.value === active[0]);
|
|
118
|
+
return `${filter.label}: ${option?.label ?? active[0]}`;
|
|
119
|
+
}
|
|
120
|
+
return `${filter.label}: ${active.length}`;
|
|
121
|
+
};
|
|
122
|
+
const anyActive = filters.some((f) => (activeValues[f.id] ?? []).length > 0);
|
|
123
|
+
const classes = [baseClass, size === "compact" && `${baseClass}--compact`, className].filter(Boolean).join(" ");
|
|
124
|
+
return /* @__PURE__ */ jsxs("div", { ...rest, ref, className: classes, children: [
|
|
125
|
+
filters.map((filter) => {
|
|
126
|
+
const isOpen = openId === filter.id;
|
|
127
|
+
const active = activeValues[filter.id] ?? [];
|
|
128
|
+
const isActive = active.length > 0;
|
|
129
|
+
const listboxId = `${generatedId}-${filter.id}-listbox`;
|
|
130
|
+
const activeDescendant = isOpen && focusedIndex >= 0 ? `${generatedId}-${filter.id}-option-${focusedIndex}` : void 0;
|
|
131
|
+
const chipClasses = [
|
|
132
|
+
`${baseClass}__chip`,
|
|
133
|
+
isActive && `${baseClass}__chip--active`,
|
|
134
|
+
isOpen && `${baseClass}__chip--open`
|
|
135
|
+
].filter(Boolean).join(" ");
|
|
136
|
+
return /* @__PURE__ */ jsxs(
|
|
137
|
+
"div",
|
|
138
|
+
{
|
|
139
|
+
className: `${baseClass}__filter`,
|
|
140
|
+
ref: (node) => {
|
|
141
|
+
if (node) filterRefs.current.set(filter.id, node);
|
|
142
|
+
else filterRefs.current.delete(filter.id);
|
|
143
|
+
},
|
|
144
|
+
children: [
|
|
145
|
+
/* @__PURE__ */ jsxs("span", { className: chipClasses, children: [
|
|
146
|
+
/* @__PURE__ */ jsxs(
|
|
147
|
+
"button",
|
|
148
|
+
{
|
|
149
|
+
type: "button",
|
|
150
|
+
className: `${baseClass}__trigger`,
|
|
151
|
+
"aria-haspopup": "listbox",
|
|
152
|
+
"aria-expanded": isOpen,
|
|
153
|
+
"aria-controls": isOpen ? listboxId : void 0,
|
|
154
|
+
"aria-activedescendant": activeDescendant,
|
|
155
|
+
onClick: () => handleTriggerClick(filter),
|
|
156
|
+
onKeyDown: (e) => handleTriggerKeyDown(e, filter),
|
|
157
|
+
children: [
|
|
158
|
+
filter.icon && /* @__PURE__ */ jsx(
|
|
159
|
+
"span",
|
|
160
|
+
{
|
|
161
|
+
className: `${baseClass}__icon material-symbols-rounded`,
|
|
162
|
+
"aria-hidden": "true",
|
|
163
|
+
children: filter.icon
|
|
164
|
+
}
|
|
165
|
+
),
|
|
166
|
+
/* @__PURE__ */ jsx("span", { className: `${baseClass}__summary`, children: summarize(filter) }),
|
|
167
|
+
/* @__PURE__ */ jsx(
|
|
168
|
+
"span",
|
|
169
|
+
{
|
|
170
|
+
className: `${baseClass}__caret material-symbols-rounded`,
|
|
171
|
+
"aria-hidden": "true",
|
|
172
|
+
children: "expand_more"
|
|
173
|
+
}
|
|
174
|
+
)
|
|
175
|
+
]
|
|
176
|
+
}
|
|
177
|
+
),
|
|
178
|
+
isActive && /* @__PURE__ */ jsx(
|
|
179
|
+
"button",
|
|
180
|
+
{
|
|
181
|
+
type: "button",
|
|
182
|
+
className: `${baseClass}__chip-clear`,
|
|
183
|
+
"aria-label": `Clear ${filter.label}`,
|
|
184
|
+
onClick: () => clearFilter(filter.id),
|
|
185
|
+
children: /* @__PURE__ */ jsx("span", { className: "material-symbols-rounded", "aria-hidden": "true", children: "close" })
|
|
186
|
+
}
|
|
187
|
+
)
|
|
188
|
+
] }),
|
|
189
|
+
isOpen && /* @__PURE__ */ jsx(
|
|
190
|
+
"ul",
|
|
191
|
+
{
|
|
192
|
+
id: listboxId,
|
|
193
|
+
className: `${baseClass}__panel`,
|
|
194
|
+
role: "listbox",
|
|
195
|
+
"aria-label": filter.label,
|
|
196
|
+
"aria-multiselectable": filter.multiple !== false || void 0,
|
|
197
|
+
children: filter.options.map((option, index) => {
|
|
198
|
+
const selected = active.includes(option.value);
|
|
199
|
+
const optionClasses = [
|
|
200
|
+
`${baseClass}__option`,
|
|
201
|
+
selected && `${baseClass}__option--selected`,
|
|
202
|
+
index === focusedIndex && `${baseClass}__option--focused`
|
|
203
|
+
].filter(Boolean).join(" ");
|
|
204
|
+
return /* @__PURE__ */ jsxs(
|
|
205
|
+
"li",
|
|
206
|
+
{
|
|
207
|
+
id: `${generatedId}-${filter.id}-option-${index}`,
|
|
208
|
+
className: optionClasses,
|
|
209
|
+
role: "option",
|
|
210
|
+
"aria-selected": selected,
|
|
211
|
+
onClick: () => toggleOption(filter, option.value),
|
|
212
|
+
children: [
|
|
213
|
+
/* @__PURE__ */ jsx(
|
|
214
|
+
"span",
|
|
215
|
+
{
|
|
216
|
+
className: `${baseClass}__option-check material-symbols-rounded`,
|
|
217
|
+
"aria-hidden": "true",
|
|
218
|
+
children: selected ? "check" : ""
|
|
219
|
+
}
|
|
220
|
+
),
|
|
221
|
+
option.label
|
|
222
|
+
]
|
|
223
|
+
},
|
|
224
|
+
option.value
|
|
225
|
+
);
|
|
226
|
+
})
|
|
227
|
+
}
|
|
228
|
+
)
|
|
229
|
+
]
|
|
230
|
+
},
|
|
231
|
+
filter.id
|
|
232
|
+
);
|
|
233
|
+
}),
|
|
234
|
+
anyActive && /* @__PURE__ */ jsx(
|
|
235
|
+
"button",
|
|
236
|
+
{
|
|
237
|
+
type: "button",
|
|
238
|
+
className: `${baseClass}__clear-all`,
|
|
239
|
+
onClick: () => applyValues({}),
|
|
240
|
+
children: clearLabel
|
|
241
|
+
}
|
|
242
|
+
)
|
|
243
|
+
] });
|
|
244
|
+
}
|
|
245
|
+
);
|
|
246
|
+
FilterBar.displayName = "FilterBar";
|
|
247
|
+
export {
|
|
248
|
+
FilterBar
|
|
249
|
+
};
|