@helloimjolopez-pathway/pathway-components 0.4.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.
- package/components/bottom-sheet/bottom-sheet-demo.jsx +145 -0
- package/components/bottom-sheet/bottom-sheet.jsx +433 -0
- package/components/dropdown/dropdown.jsx +482 -0
- package/components/search/search.jsx +1 -1
- package/components/sidenav/sidenav.jsx +1 -1
- package/components/toggle-button/toggle-button-demo.jsx +193 -0
- package/components/toggle-button/toggle-button.jsx +434 -0
- package/components/top-nav/top-nav.jsx +3 -3
- package/package.json +5 -2
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Toggle Button's standalone demo. Imports the real module, so there is nothing
|
|
3
|
+
* here that can fall out of step with the shipped component: see
|
|
4
|
+
* components/_demo/harness.jsx for why the newer demos are generated.
|
|
5
|
+
*/
|
|
6
|
+
import React, { useState } from "react";
|
|
7
|
+
import { DemoChrome, mount } from "../_demo/harness.jsx";
|
|
8
|
+
import { ToggleButton, ToggleButtonOption, SIZES } from "./toggle-button.jsx";
|
|
9
|
+
import { Widget } from "../widget/widget.jsx";
|
|
10
|
+
import { TooltipProvider } from "../tooltip/tooltip.jsx";
|
|
11
|
+
|
|
12
|
+
const U = (n) => `var(--semantic-layout-units-${n})`;
|
|
13
|
+
const C = (n) => `var(--semantic-color-${n})`;
|
|
14
|
+
const Y = (n) => `var(--semantic-type-${n})`;
|
|
15
|
+
|
|
16
|
+
const STYLES = ["Primary", "Secondary", "Tertiary"];
|
|
17
|
+
const SIZE_KEYS = Object.keys(SIZES);
|
|
18
|
+
|
|
19
|
+
const VIEW = [
|
|
20
|
+
{ value: "bar", label: "Bar", icon: "bar_chart" },
|
|
21
|
+
{ value: "line", label: "Line", icon: "show_chart" },
|
|
22
|
+
{ value: "table", label: "Table", icon: "table_rows" },
|
|
23
|
+
];
|
|
24
|
+
const MONTHLY = "Budgets are set by month.";
|
|
25
|
+
const PERIOD = [
|
|
26
|
+
{ value: "d", label: "D", disabled: true, reason: `Daily totals aren't available. ${MONTHLY}` },
|
|
27
|
+
{ value: "w", label: "W", disabled: true, reason: `Weekly totals aren't available. ${MONTHLY}` },
|
|
28
|
+
{ value: "m", label: "M" },
|
|
29
|
+
{ value: "p", label: "P" },
|
|
30
|
+
{ value: "q", label: "Q" },
|
|
31
|
+
{ value: "y", label: "Y" },
|
|
32
|
+
];
|
|
33
|
+
const RANGE = [
|
|
34
|
+
{ value: "this", label: "This fiscal year" },
|
|
35
|
+
{ value: "last", label: "Last fiscal year" },
|
|
36
|
+
{ value: "ytd", label: "Year to date" },
|
|
37
|
+
{ value: "quarter", label: "This quarter" },
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
const card = {
|
|
41
|
+
background: C("fill-surface-elevated"), border: `1px solid ${C("stroke-static-neutral-base")}`,
|
|
42
|
+
borderRadius: U("cornerradius-large"), padding: U("padding-relaxed"),
|
|
43
|
+
display: "flex", flexDirection: "column", gap: U("gap-base"), minWidth: 0,
|
|
44
|
+
};
|
|
45
|
+
const h2 = { margin: 0, fontSize: Y("font-size-m"), lineHeight: Y("line-height-m-single"), fontWeight: Y("weight-semibold"), color: C("foreground-static-neutral-bold") };
|
|
46
|
+
const small = { fontSize: Y("font-size-xs"), lineHeight: Y("line-height-xs-single"), color: C("foreground-static-neutral-subtle") };
|
|
47
|
+
|
|
48
|
+
/* A chart placeholder: bars whose heights change with the view, so a choice is
|
|
49
|
+
visible straight away, which is the job the toggle does. */
|
|
50
|
+
function Chart({ view }) {
|
|
51
|
+
const data = [62, 44, 30, 28, 46, 80, 58, 64, 40, 54, 70, 70];
|
|
52
|
+
if (view === "table") {
|
|
53
|
+
return (
|
|
54
|
+
<div style={{ display: "grid", gridTemplateColumns: "1fr auto", gap: U("gap-xtight"), ...small }}>
|
|
55
|
+
{["Jan", "Feb", "Mar", "Apr", "May", "Jun"].map((m, i) => (
|
|
56
|
+
<React.Fragment key={m}><span>{m}</span><span style={{ color: C("foreground-static-neutral-base") }}>${(data[i] * 31).toLocaleString()}</span></React.Fragment>
|
|
57
|
+
))}
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
return (
|
|
62
|
+
<div style={{ display: "flex", alignItems: "flex-end", gap: U("gap-xtight"), height: 140 }}>
|
|
63
|
+
{data.map((v, i) => (
|
|
64
|
+
<div key={i} style={{ flex: 1, height: `${v}%`, background: C("fill-chart-sequential-07"),
|
|
65
|
+
borderRadius: view === "line" ? U("cornerradius-full") : U("cornerradius-small"),
|
|
66
|
+
maxHeight: view === "line" ? 6 : undefined, alignSelf: view === "line" ? "auto" : undefined,
|
|
67
|
+
marginBottom: view === "line" ? `${v - 6}%` : 0 }} />
|
|
68
|
+
))}
|
|
69
|
+
</div>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function InWidget() {
|
|
74
|
+
const [view, setView] = useState("bar");
|
|
75
|
+
const [period, setPeriod] = useState("m");
|
|
76
|
+
const [bank, setBank] = useState("bar");
|
|
77
|
+
return (
|
|
78
|
+
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 360px), 1fr))", gap: U("gap-relaxed") }}>
|
|
79
|
+
<Widget title="Budget Compared to Actual" size="detail" inGrid={false}>
|
|
80
|
+
<div style={{ display: "flex", flexDirection: "column", gap: U("gap-base") }}>
|
|
81
|
+
<div style={{ display: "flex", justifyContent: "flex-end", minWidth: 0 }}>
|
|
82
|
+
<ToggleButton label="Chart view" options={VIEW} value={view} onChange={setView} showIcon />
|
|
83
|
+
</div>
|
|
84
|
+
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: U("gap-base"), minWidth: 0 }}>
|
|
85
|
+
<span style={{ fontSize: Y("font-size-xl"), lineHeight: Y("line-height-xl-single"), fontWeight: Y("weight-semibold"), color: C("foreground-static-neutral-strong") }}>2,000</span>
|
|
86
|
+
<ToggleButton label="Period" options={PERIOD} value={period} onChange={setPeriod} />
|
|
87
|
+
</div>
|
|
88
|
+
<Chart view={view} />
|
|
89
|
+
</div>
|
|
90
|
+
</Widget>
|
|
91
|
+
<Widget title="Bank Balances" size="detail" inGrid={false}>
|
|
92
|
+
<div style={{ display: "flex", flexDirection: "column", gap: U("gap-base") }}>
|
|
93
|
+
<div style={{ display: "flex", justifyContent: "flex-end" }}>
|
|
94
|
+
<ToggleButton label="Chart view" options={VIEW} value={bank} onChange={setBank} showIcon showLabel={false} />
|
|
95
|
+
</div>
|
|
96
|
+
<Chart view={bank} />
|
|
97
|
+
</div>
|
|
98
|
+
</Widget>
|
|
99
|
+
</div>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function Styles() {
|
|
104
|
+
const [v, setV] = useState({});
|
|
105
|
+
return (
|
|
106
|
+
<section style={card}>
|
|
107
|
+
<h2 style={h2}>Styles and sizes</h2>
|
|
108
|
+
<p style={{ ...small, margin: 0 }}>Primary for one page-level toggle per view. Secondary inside cards, widgets, dialogs and sheets. Tertiary where Secondary fits but the selection should carry brand colour.</p>
|
|
109
|
+
{STYLES.map((st) => (
|
|
110
|
+
<div key={st} style={{ display: "flex", flexDirection: "column", gap: U("gap-xtight") }}>
|
|
111
|
+
<div style={small}>{st}</div>
|
|
112
|
+
<div style={{ display: "flex", alignItems: "center", gap: U("gap-relaxed"), flexWrap: "wrap" }}>
|
|
113
|
+
{SIZE_KEYS.map((sz) => (
|
|
114
|
+
<ToggleButton key={sz} label={`${st} ${sz}`} toggleStyle={st} size={sz} options={VIEW} showIcon
|
|
115
|
+
value={v[st + sz] ?? "bar"} onChange={(x) => setV((o) => ({ ...o, [st + sz]: x }))} />
|
|
116
|
+
))}
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
))}
|
|
120
|
+
<div style={small}>Icon only</div>
|
|
121
|
+
<div style={{ display: "flex", alignItems: "center", gap: U("gap-relaxed"), flexWrap: "wrap" }}>
|
|
122
|
+
{SIZE_KEYS.map((sz) => (
|
|
123
|
+
<ToggleButton key={sz} label={`Icon only ${sz}`} size={sz} options={VIEW} showIcon showLabel={false}
|
|
124
|
+
value={v["i" + sz] ?? "bar"} onChange={(x) => setV((o) => ({ ...o, ["i" + sz]: x }))} />
|
|
125
|
+
))}
|
|
126
|
+
</div>
|
|
127
|
+
</section>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function States() {
|
|
132
|
+
const [st, setSt] = useState("Secondary");
|
|
133
|
+
const states = [null, "hover", "focused", "pressed"];
|
|
134
|
+
const names = ["Rest", "Hover", "Focused", "Pressed"];
|
|
135
|
+
return (
|
|
136
|
+
<section style={card}>
|
|
137
|
+
<h2 style={h2}>States</h2>
|
|
138
|
+
<ToggleButton label="Style" options={STYLES.map((s) => ({ value: s, label: s }))} value={st} onChange={setSt} size="XS" />
|
|
139
|
+
<div style={{ display: "grid", gridTemplateColumns: "auto repeat(5, max-content)", gap: `${U("gap-base")} ${U("gap-relaxed")}`, alignItems: "center", overflowX: "auto" }}>
|
|
140
|
+
<span />{[...names, "Unavailable"].map((n) => <span key={n} style={small}>{n}</span>)}
|
|
141
|
+
{[false, true].map((sel) => (
|
|
142
|
+
<React.Fragment key={String(sel)}>
|
|
143
|
+
<span style={small}>{sel ? "Selected" : "Unselected"}</span>
|
|
144
|
+
{states.map((s, i) => (
|
|
145
|
+
<ToggleButtonOption key={i} toggleStyle={st} size="M" selected={sel} position="Only" label="Option" icon="bar_chart" forceState={s} tabIndex={-1} />
|
|
146
|
+
))}
|
|
147
|
+
{sel ? <span style={small}>never</span>
|
|
148
|
+
: <ToggleButtonOption toggleStyle={st} size="M" position="Only" label="Option" icon="bar_chart" disabled tabIndex={-1} />}
|
|
149
|
+
</React.Fragment>
|
|
150
|
+
))}
|
|
151
|
+
</div>
|
|
152
|
+
</section>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function Narrow() {
|
|
157
|
+
const [w, setW] = useState(520);
|
|
158
|
+
const [period, setPeriod] = useState("m");
|
|
159
|
+
const [range, setRange] = useState("this");
|
|
160
|
+
return (
|
|
161
|
+
<section style={card}>
|
|
162
|
+
<h2 style={h2}>Narrow space</h2>
|
|
163
|
+
<p style={{ ...small, margin: 0 }}>Drag the slider. Unavailable options move into More first, then options from the end. The selected option always stays in the row.</p>
|
|
164
|
+
<label style={{ ...small, display: "flex", alignItems: "center", gap: U("gap-tight") }}>
|
|
165
|
+
Container width {w}px
|
|
166
|
+
<input type="range" min={120} max={640} value={w} onChange={(e) => setW(+e.target.value)} style={{ flex: 1, maxWidth: 320 }} />
|
|
167
|
+
</label>
|
|
168
|
+
<div style={{ width: w, maxWidth: "100%", padding: U("padding-xtight"), border: `1px dashed ${C("stroke-static-neutral-bold")}`, borderRadius: U("cornerradius-base"), display: "flex", flexDirection: "column", gap: U("gap-base") }}>
|
|
169
|
+
<ToggleButton label="Period" options={PERIOD} value={period} onChange={setPeriod} />
|
|
170
|
+
<ToggleButton label="Range" options={RANGE} value={range} onChange={setRange} size="M" />
|
|
171
|
+
</div>
|
|
172
|
+
</section>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function App() {
|
|
177
|
+
return (
|
|
178
|
+
<DemoChrome title="Toggle Button"
|
|
179
|
+
blurb={"Pick one of two to five options; one is always selected. Keyboard: Tab to the group, arrows move and select, " +
|
|
180
|
+
"Home and End jump. Hover, focus or tap an unavailable option to see why."}>
|
|
181
|
+
<TooltipProvider>
|
|
182
|
+
<div style={{ display: "flex", flexDirection: "column", gap: U("gap-relaxed") }}>
|
|
183
|
+
<InWidget />
|
|
184
|
+
<Narrow />
|
|
185
|
+
<Styles />
|
|
186
|
+
<States />
|
|
187
|
+
</div>
|
|
188
|
+
</TooltipProvider>
|
|
189
|
+
</DemoChrome>
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
mount(<App />);
|
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Toggle Button, Pathway Design System
|
|
3
|
+
*
|
|
4
|
+
* Importable React component module. Source of truth for the Toggle Button
|
|
5
|
+
* implementation; the standalone demo (toggle-button.html), the Storybook
|
|
6
|
+
* stories and the <pathway-toggle-button> web component all consume this.
|
|
7
|
+
*
|
|
8
|
+
* A row of two to five options where exactly one is selected at all times. It
|
|
9
|
+
* switches how nearby content is shown: a chart view, a period. To assistive
|
|
10
|
+
* technology it is a radio group.
|
|
11
|
+
*
|
|
12
|
+
* Spec: components/toggle-button/toggle-button-spec.md
|
|
13
|
+
* Figma: https://www.figma.com/design/3sw45aVcngFAmpbP6cfrXP/?node-id=40018458-121921
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import React, { forwardRef, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
17
|
+
import { Tooltip } from "../tooltip/tooltip.jsx";
|
|
18
|
+
import { Dropdown, DropdownItem } from "../dropdown/dropdown.jsx";
|
|
19
|
+
import { Icon } from "../icon/icon.jsx";
|
|
20
|
+
import { useReducedMotion } from "../_shared/use-reduced-motion.jsx";
|
|
21
|
+
|
|
22
|
+
// ─── TOKEN HELPERS ────────────────────────────────────────────────────────────
|
|
23
|
+
// SC = semantic-color (themes/*.css), SL = semantic-layout-units (layout.css),
|
|
24
|
+
// CL = contextual-layout-units (layout-contextual.css), ST = semantic-type
|
|
25
|
+
// (type.css), M = motion (motion.css). PU = primitive unit, used ONLY where no
|
|
26
|
+
// semantic token fits (the half-height radius on XS, M and L, spec §3.4).
|
|
27
|
+
const SC = (p) => `var(--semantic-color-${p})`;
|
|
28
|
+
const SL = (p) => `var(--semantic-layout-units-${p})`;
|
|
29
|
+
const CL = (p) => `var(--contextual-layout-units-${p})`;
|
|
30
|
+
const ST = (p) => `var(--semantic-type-${p})`;
|
|
31
|
+
const M = (p) => `var(--motion-${p})`;
|
|
32
|
+
const PU = (n) => `var(--primitive-unit-unit-${n})`;
|
|
33
|
+
|
|
34
|
+
const ramp = (prefix) => ({ rest: SC(`${prefix}-rest`), hover: SC(`${prefix}-hover`), pressed: SC(`${prefix}-pressed`) });
|
|
35
|
+
|
|
36
|
+
// ─── COLOUR TOKENS ────────────────────────────────────────────────────────────
|
|
37
|
+
// Every style shares the unselected option; the styles differ only in how the
|
|
38
|
+
// selected option looks. Read off Figma, nodes 40018457:867 / 40018458:975 /
|
|
39
|
+
// 40018458:1732, spec §3.1 to §3.3.
|
|
40
|
+
export const UNSELECTED = {
|
|
41
|
+
fill: { ...ramp("fill-action-secondary"), disabled: SC("fill-action-disabled") },
|
|
42
|
+
fg: { ...ramp("foreground-action-secondary"), disabled: SC("foreground-action-disabled") },
|
|
43
|
+
stroke: null,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const STYLES = {
|
|
47
|
+
Primary: {
|
|
48
|
+
fill: ramp("fill-action-primary-strong"),
|
|
49
|
+
// On Strong has one step: the label stays the same through hover and pressed.
|
|
50
|
+
fg: { rest: SC("foreground-action-primary-on-strong"), hover: SC("foreground-action-primary-on-strong"), pressed: SC("foreground-action-primary-on-strong") },
|
|
51
|
+
stroke: null,
|
|
52
|
+
},
|
|
53
|
+
Secondary: {
|
|
54
|
+
// Documented exception, spec §3.1: no action token exists for a raised
|
|
55
|
+
// neutral selection, so the fill borrows the elevated surface in every
|
|
56
|
+
// state and the outline carries hover and pressed.
|
|
57
|
+
fill: { rest: SC("fill-surface-elevated"), hover: SC("fill-surface-elevated"), pressed: SC("fill-surface-elevated") },
|
|
58
|
+
fg: ramp("foreground-action-secondary"),
|
|
59
|
+
stroke: ramp("stroke-action-secondary"),
|
|
60
|
+
},
|
|
61
|
+
Tertiary: {
|
|
62
|
+
fill: ramp("fill-action-primary-subtle"),
|
|
63
|
+
fg: ramp("foreground-action-primary-on-subtle"),
|
|
64
|
+
stroke: ramp("stroke-action-primary-strong"),
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// ─── SHARED LAYOUT AND MOTION ─────────────────────────────────────────────────
|
|
69
|
+
export const T = {
|
|
70
|
+
gap: SL("gap-xxxtight"), // 2px between options
|
|
71
|
+
border: SL("borderwidth-base"), // 1px, drawn inside
|
|
72
|
+
touchW: SL("accessibility-touch-target-aa-width"), // 44
|
|
73
|
+
touchH: SL("accessibility-touch-target-aa-height"), // 44
|
|
74
|
+
// Focus ring drawn the way Button draws it (spec §3.6): an outline follows the
|
|
75
|
+
// option's own radius, and the offset shows the surface behind, so there is
|
|
76
|
+
// no white halo on a dark page.
|
|
77
|
+
focusOutline: `${CL("focusring-width")} solid ${SC("stroke-focusring-base")}`,
|
|
78
|
+
focusOffset: CL("focusring-offset"),
|
|
79
|
+
colour: `${M("duration-2")} ${M("easing-standard")}`,
|
|
80
|
+
shape: `${M("duration-4")} ${M("easing-spring-soft")}`,
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// Button's label styles, so a Toggle Button and a Button of the same size read
|
|
84
|
+
// the same. Tracking is per size, as Figma's Label/Button styles are.
|
|
85
|
+
const LABEL = (size, lineHeight, tracking) => ({
|
|
86
|
+
fontFamily: ST("family-brand"),
|
|
87
|
+
fontSize: ST(`font-size-${size}`),
|
|
88
|
+
fontWeight: ST("weight-medium"),
|
|
89
|
+
lineHeight: ST(`line-height-${lineHeight}`),
|
|
90
|
+
letterSpacing: ST(`letter-spacing-${tracking}`),
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// ─── SIZE TABLE ───────────────────────────────────────────────────────────────
|
|
94
|
+
// height and icon are measurements (spec §3.5); every other value is a token.
|
|
95
|
+
// outer = the fully round radius, half the height: only S has a semantic token
|
|
96
|
+
// at that value, XS / M / L bind the primitive units Figma binds.
|
|
97
|
+
export const SIZES = {
|
|
98
|
+
XS: { height: 28, padH: SL("padding-xtight"), inner: SL("cornerradius-small"), outer: PU(14), gap: SL("gap-xtight"), icon: 14, ...LABEL("xs", "xs-single", "extraspacious") },
|
|
99
|
+
S: { height: 32, padH: SL("padding-tight"), inner: SL("cornerradius-small"), outer: SL("cornerradius-xlarge"), gap: SL("gap-tight"), icon: 16, ...LABEL("s", "s-single", "spacious") },
|
|
100
|
+
M: { height: 40, padH: SL("padding-base"), inner: SL("cornerradius-base"), outer: PU(20), gap: SL("gap-tight"), icon: 18, ...LABEL("r", "r-single", "wide") },
|
|
101
|
+
L: { height: 48, padH: SL("padding-relaxed"), inner: SL("cornerradius-base"), outer: PU(24), gap: SL("gap-tight"), icon: 20, ...LABEL("m", "m-single", "wide") },
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const radiusFor = (sz, position, selected) => {
|
|
105
|
+
if (selected) return sz.outer;
|
|
106
|
+
const o = sz.outer, i = sz.inner;
|
|
107
|
+
if (position === "Only") return o;
|
|
108
|
+
if (position === "First") return `${o} ${i} ${i} ${o}`;
|
|
109
|
+
if (position === "Last") return `${i} ${o} ${o} ${i}`;
|
|
110
|
+
return i;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// ─── ToggleButtonOption ───────────────────────────────────────────────────────
|
|
114
|
+
/**
|
|
115
|
+
* One option: Figma's ToggleButton.Option building block. The row renders
|
|
116
|
+
* these; on its own it is for state matrices and documentation.
|
|
117
|
+
*
|
|
118
|
+
* @param {"Primary"|"Secondary"|"Tertiary"} toggleStyle Figma `Style`. Not `style`, which is React's inline style prop (Button does the same with `buttonStyle`)
|
|
119
|
+
* @param {"XS"|"S"|"M"|"L"} size
|
|
120
|
+
* @param {boolean} selected Figma `Selected`
|
|
121
|
+
* @param {"First"|"Middle"|"Last"|"Only"} position which corners are outer; set by the row
|
|
122
|
+
* @param {string} label Figma `Label`; also the name of an icon-only option
|
|
123
|
+
* @param {string} icon Material Symbols ligature; FILL 0 unselected, FILL 1 selected
|
|
124
|
+
* @param {boolean} showIcon Figma `Show Icon`
|
|
125
|
+
* @param {boolean} showLabel Figma `Show Label`
|
|
126
|
+
* @param {boolean} disabled unavailable: aria-disabled, never the native attribute
|
|
127
|
+
* @param {boolean} fill stretch to share the row's width
|
|
128
|
+
* @param {"hover"|"pressed"|"focused"|null} forceState visual only, for state matrices
|
|
129
|
+
* @param {"radio"|"button"} as the More trigger renders as a plain button
|
|
130
|
+
*/
|
|
131
|
+
export const ToggleButtonOption = forwardRef(function ToggleButtonOption({
|
|
132
|
+
toggleStyle = "Secondary",
|
|
133
|
+
size = "S",
|
|
134
|
+
selected = false,
|
|
135
|
+
position = "Middle",
|
|
136
|
+
label = "Label",
|
|
137
|
+
icon,
|
|
138
|
+
showIcon = true,
|
|
139
|
+
showLabel = true,
|
|
140
|
+
disabled = false,
|
|
141
|
+
fill = false,
|
|
142
|
+
forceState = null,
|
|
143
|
+
as = "radio",
|
|
144
|
+
onPointerEnter, onPointerLeave, onPointerDown, onPointerUp, onFocus, onBlur, onKeyDown, onKeyUp, onClick,
|
|
145
|
+
className = "",
|
|
146
|
+
...rest
|
|
147
|
+
}, ref) {
|
|
148
|
+
const reduced = useReducedMotion();
|
|
149
|
+
const [hover, setHover] = useState(false);
|
|
150
|
+
const [pressed, setPressed] = useState(false);
|
|
151
|
+
const [focused, setFocused] = useState(false);
|
|
152
|
+
|
|
153
|
+
const sz = SIZES[size] || SIZES.S;
|
|
154
|
+
const look = selected ? (STYLES[toggleStyle] || STYLES.Secondary) : UNSELECTED;
|
|
155
|
+
const state = disabled ? "disabled"
|
|
156
|
+
: forceState === "pressed" || pressed ? "pressed"
|
|
157
|
+
: forceState === "hover" || hover ? "hover"
|
|
158
|
+
: "rest";
|
|
159
|
+
const ring = !disabled && (forceState === "focused" || focused);
|
|
160
|
+
const iconOnly = showIcon && !showLabel;
|
|
161
|
+
|
|
162
|
+
const main = {
|
|
163
|
+
display: "flex", alignItems: "center", justifyContent: "center", gap: sz.gap,
|
|
164
|
+
boxSizing: "border-box", height: sz.height, minWidth: T.touchW, width: fill ? "100%" : undefined,
|
|
165
|
+
paddingLeft: iconOnly ? 0 : sz.padH, paddingRight: iconOnly ? 0 : sz.padH,
|
|
166
|
+
borderRadius: radiusFor(sz, position, selected),
|
|
167
|
+
background: look.fill[state] || look.fill.rest,
|
|
168
|
+
color: look.fg[state] || look.fg.rest,
|
|
169
|
+
// Inset so the outline never adds width: selecting must not move the row.
|
|
170
|
+
boxShadow: look.stroke ? `inset 0 0 0 ${T.border} ${look.stroke[state] || look.stroke.rest}` : "none",
|
|
171
|
+
outline: ring ? T.focusOutline : "none",
|
|
172
|
+
outlineOffset: ring ? T.focusOffset : undefined,
|
|
173
|
+
fontFamily: sz.fontFamily, fontSize: sz.fontSize, fontWeight: sz.fontWeight,
|
|
174
|
+
lineHeight: sz.lineHeight, letterSpacing: sz.letterSpacing, whiteSpace: "nowrap",
|
|
175
|
+
transition: reduced ? "none"
|
|
176
|
+
: `background-color ${T.colour}, color ${T.colour}, box-shadow ${T.colour}, border-radius ${T.shape}`,
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const outer = {
|
|
180
|
+
position: "relative", zIndex: ring ? 1 : undefined,
|
|
181
|
+
display: "inline-flex", alignItems: "center", justifyContent: "center",
|
|
182
|
+
minHeight: T.touchH, minWidth: T.touchW, flex: fill ? "1 1 0" : "0 0 auto",
|
|
183
|
+
padding: 0, margin: 0, border: 0, background: "transparent", outline: "none",
|
|
184
|
+
font: "inherit", color: "inherit", cursor: disabled ? "not-allowed" : "pointer",
|
|
185
|
+
WebkitTapHighlightColor: "transparent",
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const radio = as === "radio";
|
|
189
|
+
return (
|
|
190
|
+
<button
|
|
191
|
+
ref={ref}
|
|
192
|
+
type="button"
|
|
193
|
+
role={radio ? "radio" : undefined}
|
|
194
|
+
aria-checked={radio ? selected : undefined}
|
|
195
|
+
aria-disabled={disabled || undefined}
|
|
196
|
+
aria-label={iconOnly ? label : undefined}
|
|
197
|
+
data-selected={selected || undefined}
|
|
198
|
+
className={`pw-toggle-button__option ${className}`.trim()}
|
|
199
|
+
style={outer}
|
|
200
|
+
{...rest}
|
|
201
|
+
onPointerEnter={(e) => { setHover(true); onPointerEnter?.(e); }}
|
|
202
|
+
onPointerLeave={(e) => { setHover(false); setPressed(false); onPointerLeave?.(e); }}
|
|
203
|
+
onPointerDown={(e) => { if (!disabled) setPressed(true); onPointerDown?.(e); }}
|
|
204
|
+
onPointerUp={(e) => { setPressed(false); onPointerUp?.(e); }}
|
|
205
|
+
onFocus={(e) => { let v = true; try { v = e.target.matches(":focus-visible"); } catch { /* older browsers */ } setFocused(v); onFocus?.(e); }}
|
|
206
|
+
onBlur={(e) => { setFocused(false); setPressed(false); onBlur?.(e); }}
|
|
207
|
+
onKeyDown={(e) => { if (!disabled && (e.key === " " || e.key === "Enter")) setPressed(true); onKeyDown?.(e); }}
|
|
208
|
+
onKeyUp={(e) => { setPressed(false); onKeyUp?.(e); }}
|
|
209
|
+
onClick={onClick}
|
|
210
|
+
>
|
|
211
|
+
<span className="pw-toggle-button__main" style={main}>
|
|
212
|
+
{showIcon && icon && <Icon name={icon} size={sz.icon} fill={selected ? 1 : 0} />}
|
|
213
|
+
{showLabel && <span className="pw-toggle-button__label">{label}</span>}
|
|
214
|
+
</span>
|
|
215
|
+
</button>
|
|
216
|
+
);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// ─── Overflow ─────────────────────────────────────────────────────────────────
|
|
220
|
+
// Which options stay in the row (spec §10): unavailable options leave first,
|
|
221
|
+
// then available ones from the end; the selected option never leaves; the ones
|
|
222
|
+
// that stay keep their order.
|
|
223
|
+
export function fitOptions(options, widths, selectedValue, room, moreWidth, gapPx) {
|
|
224
|
+
const total = (ids) => ids.reduce((s, i) => s + widths[i], 0) + Math.max(0, ids.length - 1) * gapPx;
|
|
225
|
+
let keep = options.map((_, i) => i);
|
|
226
|
+
if (total(keep) <= room) return { visible: keep, hidden: [] };
|
|
227
|
+
const budget = room - moreWidth - gapPx;
|
|
228
|
+
const order = [
|
|
229
|
+
...keep.filter((i) => options[i].disabled).reverse(),
|
|
230
|
+
...keep.filter((i) => !options[i].disabled && options[i].value !== selectedValue).reverse(),
|
|
231
|
+
];
|
|
232
|
+
for (const i of order) {
|
|
233
|
+
if (total(keep) <= budget) break;
|
|
234
|
+
keep = keep.filter((k) => k !== i);
|
|
235
|
+
}
|
|
236
|
+
return { visible: keep, hidden: options.map((_, i) => i).filter((i) => !keep.includes(i)) };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const pxOf = (el, name, fallback) => {
|
|
240
|
+
if (typeof window === "undefined" || !el) return fallback;
|
|
241
|
+
const v = parseFloat(getComputedStyle(el).getPropertyValue(name));
|
|
242
|
+
return Number.isFinite(v) ? v : fallback;
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
// ─── ToggleButton ─────────────────────────────────────────────────────────────
|
|
246
|
+
/**
|
|
247
|
+
* @param {Array<{value:string,label:string,icon?:string,disabled?:boolean,reason?:string}>} options 2 to 5
|
|
248
|
+
* @param {string} value controlled selection
|
|
249
|
+
* @param {string} defaultValue uncontrolled start; otherwise the first available option
|
|
250
|
+
* @param {Function} onChange (value) => void
|
|
251
|
+
* @param {string} label the group's accessible name, required
|
|
252
|
+
* @param {"Primary"|"Secondary"|"Tertiary"} toggleStyle Figma `Style`
|
|
253
|
+
* @param {"XS"|"S"|"M"|"L"} size Figma `Size`
|
|
254
|
+
* @param {boolean} showIcon Figma `Show Icon`, one content type per row
|
|
255
|
+
* @param {boolean} showLabel Figma `Show Label`
|
|
256
|
+
* @param {boolean} fill stretch to the container; options share the width
|
|
257
|
+
* @param {string} moreLabel the More trigger's text, for translation
|
|
258
|
+
*/
|
|
259
|
+
export function ToggleButton({
|
|
260
|
+
options = [],
|
|
261
|
+
value: valueProp,
|
|
262
|
+
defaultValue,
|
|
263
|
+
onChange,
|
|
264
|
+
label,
|
|
265
|
+
toggleStyle = "Secondary",
|
|
266
|
+
size = "S",
|
|
267
|
+
showIcon = false,
|
|
268
|
+
showLabel = true,
|
|
269
|
+
fill = false,
|
|
270
|
+
moreLabel = "More",
|
|
271
|
+
className = "",
|
|
272
|
+
style,
|
|
273
|
+
}) {
|
|
274
|
+
const firstAvailable = options.find((o) => !o.disabled)?.value;
|
|
275
|
+
const controlled = valueProp !== undefined;
|
|
276
|
+
const [inner, setInner] = useState(defaultValue ?? firstAvailable);
|
|
277
|
+
const value = controlled ? valueProp : inner;
|
|
278
|
+
|
|
279
|
+
const select = useCallback((v) => {
|
|
280
|
+
const opt = options.find((o) => o.value === v);
|
|
281
|
+
if (!opt || opt.disabled || v === value) return;
|
|
282
|
+
if (!controlled) setInner(v);
|
|
283
|
+
onChange?.(v);
|
|
284
|
+
}, [options, value, controlled, onChange]);
|
|
285
|
+
|
|
286
|
+
// ── measure every option at its natural width, then fit the row ──
|
|
287
|
+
const wrapRef = useRef(null);
|
|
288
|
+
const measureRef = useRef(null);
|
|
289
|
+
const btnRefs = useRef({});
|
|
290
|
+
const [fit, setFit] = useState({ visible: options.map((_, i) => i), hidden: [], natural: null });
|
|
291
|
+
const [focusAfter, setFocusAfter] = useState(null);
|
|
292
|
+
|
|
293
|
+
const refit = useCallback(() => {
|
|
294
|
+
const wrap = wrapRef.current, meas = measureRef.current;
|
|
295
|
+
if (!wrap || !meas) return;
|
|
296
|
+
const items = [...meas.querySelectorAll("[data-measure]")];
|
|
297
|
+
const widths = items.slice(0, options.length).map((n) => n.getBoundingClientRect().width);
|
|
298
|
+
const moreW = items[options.length]?.getBoundingClientRect().width || 0;
|
|
299
|
+
const gapPx = pxOf(wrap, "--semantic-layout-units-gap-xxxtight", 2);
|
|
300
|
+
const natural = widths.reduce((s, w) => s + w, 0) + Math.max(0, widths.length - 1) * gapPx;
|
|
301
|
+
const room = wrap.getBoundingClientRect().width;
|
|
302
|
+
const next = fitOptions(options, widths, value, Math.max(room, 0), moreW, gapPx);
|
|
303
|
+
setFit((f) => (f.natural === natural && f.visible.join() === next.visible.join() ? f : { ...next, natural }));
|
|
304
|
+
}, [options, value]);
|
|
305
|
+
|
|
306
|
+
useLayoutEffect(() => { refit(); }, [refit, size, showIcon, showLabel, toggleStyle]);
|
|
307
|
+
useEffect(() => {
|
|
308
|
+
if (typeof ResizeObserver === "undefined") return undefined;
|
|
309
|
+
// Watching the measure layer as well as the wrapper re-fits once the icon
|
|
310
|
+
// font loads: before it does, ligatures render as words and look too wide.
|
|
311
|
+
const ro = new ResizeObserver(() => refit());
|
|
312
|
+
if (wrapRef.current) ro.observe(wrapRef.current);
|
|
313
|
+
if (measureRef.current) ro.observe(measureRef.current);
|
|
314
|
+
return () => ro.disconnect();
|
|
315
|
+
}, [refit]);
|
|
316
|
+
|
|
317
|
+
useEffect(() => {
|
|
318
|
+
if (focusAfter == null) return undefined;
|
|
319
|
+
const id = requestAnimationFrame(() => { btnRefs.current[focusAfter]?.focus(); setFocusAfter(null); });
|
|
320
|
+
return () => cancelAnimationFrame(id);
|
|
321
|
+
}, [focusAfter, fit]);
|
|
322
|
+
|
|
323
|
+
const visible = fit.visible.filter((i) => i < options.length);
|
|
324
|
+
const hidden = fit.hidden.filter((i) => i < options.length);
|
|
325
|
+
const showMore = hidden.length > 0;
|
|
326
|
+
const iconOnly = showIcon && !showLabel;
|
|
327
|
+
|
|
328
|
+
// ── keyboard: roving tabindex over the visible options (spec §12) ──
|
|
329
|
+
const onKey = (e, index) => {
|
|
330
|
+
const at = visible.indexOf(index);
|
|
331
|
+
const go = (i) => {
|
|
332
|
+
const opt = options[visible[i]];
|
|
333
|
+
btnRefs.current[opt.value]?.focus();
|
|
334
|
+
if (!opt.disabled) select(opt.value);
|
|
335
|
+
};
|
|
336
|
+
const avail = visible.filter((i) => !options[i].disabled);
|
|
337
|
+
if (e.key === "ArrowRight" || e.key === "ArrowDown") { e.preventDefault(); go((at + 1) % visible.length); }
|
|
338
|
+
else if (e.key === "ArrowLeft" || e.key === "ArrowUp") { e.preventDefault(); go((at - 1 + visible.length) % visible.length); }
|
|
339
|
+
else if (e.key === "Home" && avail.length) { e.preventDefault(); go(visible.indexOf(avail[0])); }
|
|
340
|
+
else if (e.key === "End" && avail.length) { e.preventDefault(); go(visible.indexOf(avail[avail.length - 1])); }
|
|
341
|
+
else if (e.key === " " || e.key === "Enter") { e.preventDefault(); select(options[index].value); }
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
const count = visible.length + (showMore ? 1 : 0);
|
|
345
|
+
const positionOf = (slot) => (count === 1 ? "Only" : slot === 0 ? "First" : slot === count - 1 ? "Last" : "Middle");
|
|
346
|
+
const shared = { toggleStyle, size, showIcon, showLabel, fill };
|
|
347
|
+
|
|
348
|
+
const renderOption = (i, slot) => {
|
|
349
|
+
const o = options[i];
|
|
350
|
+
const selected = o.value === value;
|
|
351
|
+
const button = (
|
|
352
|
+
<ToggleButtonOption
|
|
353
|
+
key={o.value}
|
|
354
|
+
ref={(n) => { btnRefs.current[o.value] = n; }}
|
|
355
|
+
{...shared}
|
|
356
|
+
label={o.label}
|
|
357
|
+
icon={o.icon}
|
|
358
|
+
selected={selected}
|
|
359
|
+
disabled={!!o.disabled}
|
|
360
|
+
position={positionOf(slot)}
|
|
361
|
+
tabIndex={selected ? 0 : -1}
|
|
362
|
+
onClick={() => select(o.value)}
|
|
363
|
+
onKeyDown={(e) => onKey(e, i)}
|
|
364
|
+
/>
|
|
365
|
+
);
|
|
366
|
+
if (o.disabled && o.reason) {
|
|
367
|
+
// Icon-only: the trigger keeps its name and the tooltip shows name and reason.
|
|
368
|
+
return (
|
|
369
|
+
<Tooltip key={o.value} content={iconOnly ? `${o.label}. ${o.reason}` : o.reason} describe={o.reason}
|
|
370
|
+
purpose="description" persistOnPress>
|
|
371
|
+
{button}
|
|
372
|
+
</Tooltip>
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
if (iconOnly) return <Tooltip key={o.value} content={o.label} purpose="label">{button}</Tooltip>;
|
|
376
|
+
return button;
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
const wrapStyle = {
|
|
380
|
+
display: fill ? "flex" : "inline-flex", alignItems: "center", gap: T.gap,
|
|
381
|
+
// Width is the natural width, capped by the container, so the row always
|
|
382
|
+
// knows how much room it has, even after options have moved into More.
|
|
383
|
+
width: fill ? "100%" : (fit.natural != null ? fit.natural : undefined),
|
|
384
|
+
maxWidth: "100%", minWidth: 0, flexShrink: 1, position: "relative", verticalAlign: "middle",
|
|
385
|
+
...style,
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
return (
|
|
389
|
+
<div ref={wrapRef} className={`pw-toggle-button ${className}`.trim()} style={wrapStyle}>
|
|
390
|
+
<div role="radiogroup" aria-label={label}
|
|
391
|
+
style={{ display: "flex", alignItems: "center", gap: T.gap, flex: fill ? "1 1 auto" : "0 0 auto", minWidth: 0 }}>
|
|
392
|
+
{visible.map((i, slot) => renderOption(i, slot))}
|
|
393
|
+
</div>
|
|
394
|
+
|
|
395
|
+
{showMore && (
|
|
396
|
+
<Dropdown
|
|
397
|
+
align="end"
|
|
398
|
+
label={iconOnly ? "More options" : moreLabel}
|
|
399
|
+
renderTrigger={({ title, ...trigger }) => (
|
|
400
|
+
<ToggleButtonOption {...shared} {...trigger} as="button" fill={false}
|
|
401
|
+
label={iconOnly ? "More options" : moreLabel} icon={iconOnly ? "more_horiz" : undefined}
|
|
402
|
+
position={positionOf(count - 1)} />
|
|
403
|
+
)}
|
|
404
|
+
>
|
|
405
|
+
{hidden.map((i) => {
|
|
406
|
+
const o = options[i];
|
|
407
|
+
return (
|
|
408
|
+
<DropdownItem key={o.value} label={o.label} icon={showIcon ? o.icon : undefined}
|
|
409
|
+
disabled={!!o.disabled} note={o.disabled ? o.reason : undefined}
|
|
410
|
+
onSelect={() => { select(o.value); setFocusAfter(o.value); }} />
|
|
411
|
+
);
|
|
412
|
+
})}
|
|
413
|
+
</Dropdown>
|
|
414
|
+
)}
|
|
415
|
+
|
|
416
|
+
{/* Measure layer: every option and the More trigger at natural size, out
|
|
417
|
+
of view and out of the accessibility tree. */}
|
|
418
|
+
<div ref={measureRef} aria-hidden="true" inert=""
|
|
419
|
+
style={{ position: "absolute", left: 0, top: 0, visibility: "hidden", pointerEvents: "none", display: "flex", gap: T.gap, whiteSpace: "nowrap" }}>
|
|
420
|
+
{options.map((o) => (
|
|
421
|
+
<span key={o.value} data-measure="" style={{ display: "inline-flex" }}>
|
|
422
|
+
<ToggleButtonOption {...shared} fill={false} label={o.label} icon={o.icon} selected={o.value === value} tabIndex={-1} />
|
|
423
|
+
</span>
|
|
424
|
+
))}
|
|
425
|
+
<span data-measure="" style={{ display: "inline-flex" }}>
|
|
426
|
+
<ToggleButtonOption {...shared} fill={false} as="button" label={iconOnly ? "More options" : moreLabel}
|
|
427
|
+
icon={iconOnly ? "more_horiz" : undefined} tabIndex={-1} />
|
|
428
|
+
</span>
|
|
429
|
+
</div>
|
|
430
|
+
</div>
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
export default ToggleButton;
|
|
@@ -532,7 +532,7 @@ export function OrgSwitcher({ org, open, onToggle, mobile = false, showCampus =
|
|
|
532
532
|
border: "none",
|
|
533
533
|
// Hover and pressed thicken to Button/BorderWidth/Selected (1px), as
|
|
534
534
|
// Figma binds them on 40006819:14582 and 40006933:15754.
|
|
535
|
-
boxShadow: `inset 0 0 0 ${open || hov ? "var(--contextual-layout-units-button-borderwidth-selected)" : "var(--contextual-layout-units-button-borderwidth-rest)"} ${open ? T.orgStrokePressed : hov ? T.orgStrokeHover : T.orgStroke}`,
|
|
535
|
+
boxShadow: `inset 0 0 0 ${open || hov ? "var(--contextual-layout-units-button-borderwidth-selected, var(--contextual-layout-units-button-borderwidth, 1px))" : "var(--contextual-layout-units-button-borderwidth-rest, var(--contextual-layout-units-button-borderwidth, 0.75px))"} ${open ? T.orgStrokePressed : hov ? T.orgStrokeHover : T.orgStroke}`,
|
|
536
536
|
cursor: "pointer", color: fg, fontFamily: "inherit",
|
|
537
537
|
transition: "background var(--motion-duration-3) var(--motion-easing-standard), box-shadow var(--motion-duration-3) var(--motion-easing-standard)",
|
|
538
538
|
}}
|
|
@@ -862,7 +862,7 @@ function NewCoOrgSwitcher({ org, open, onToggle, compact }) {
|
|
|
862
862
|
padding: compact ? `${u("padding-xtight")} ${u("padding-xxxtight")} ${u("padding-xtight")} ${u("padding-xtight")}`
|
|
863
863
|
: `${L.orgPadV} ${L.orgPadV} ${L.orgPadV} ${L.orgPadL}`,
|
|
864
864
|
borderRadius: u("cornerradius-full"), border: "none", background: fill, cursor: "pointer", fontFamily: "inherit",
|
|
865
|
-
boxShadow: `inset 0 0 0 var(--contextual-layout-units-button-borderwidth-rest) ${T.orgStroke}`,
|
|
865
|
+
boxShadow: `inset 0 0 0 var(--contextual-layout-units-button-borderwidth-rest, var(--contextual-layout-units-button-borderwidth, 0.75px)) ${T.orgStroke}`,
|
|
866
866
|
transition: "background var(--motion-duration-3) var(--motion-easing-standard)" }}>
|
|
867
867
|
<span aria-hidden="true" style={{ ...NC_TYPE.menuBase, color: T.monoBase, whiteSpace: "nowrap", overflow: "hidden",
|
|
868
868
|
textOverflow: "ellipsis", minWidth: 0, maxWidth: compact ? L.orgNameMaxMobile : L.orgNameMax }}>
|
|
@@ -888,7 +888,7 @@ function PillarTrigger({ label, open, onToggle }) {
|
|
|
888
888
|
style={{ display: "flex", alignItems: "center", gap: "var(--semantic-layout-units-gap-xxxtight)", height: L.modInnerH,
|
|
889
889
|
boxSizing: "border-box", padding: `0 10px 0 ${u("padding-medium")}`, /* pathway-allow: Figma Pillar.Trigger right padding 10 has no token (gap logged in the spec) */
|
|
890
890
|
borderRadius: u("cornerradius-full"), border: "none", background: fill, cursor: "pointer", fontFamily: "inherit",
|
|
891
|
-
boxShadow: `inset 0 0 0 var(--contextual-layout-units-button-borderwidth-rest) ${T.orgStroke}`, color: T.monoBase }}>
|
|
891
|
+
boxShadow: `inset 0 0 0 var(--contextual-layout-units-button-borderwidth-rest, var(--contextual-layout-units-button-borderwidth, 0.75px)) ${T.orgStroke}`, color: T.monoBase }}>
|
|
892
892
|
<span style={{ ...NC_TYPE.sectionSm, whiteSpace: "nowrap" }}>{label}</span>
|
|
893
893
|
<span style={{ width: L.modChevronBox, height: L.modChevronBox, display: "inline-flex", alignItems: "center", justifyContent: "center" }}>
|
|
894
894
|
<Icon name="expand_more" size={L.chevronGlyph} style={{ color: T.monoBase }} />
|