@helloimjolopez-pathway/pathway-components 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -610,7 +610,7 @@ export function TopNavSearch({
610
610
  boxSizing: "border-box",
611
611
  borderRadius: "var(--semantic-layout-units-cornerradius-full)",
612
612
  background: hov ? T.collapsedBtnHover : T.collapsedBtnFill,
613
- border: `var(--contextual-layout-units-button-borderwidth-rest) solid ${hov ? T.collapsedBtnBorderHov : T.collapsedBtnBorder}`,
613
+ border: `var(--contextual-layout-units-button-borderwidth-rest, var(--contextual-layout-units-button-borderwidth, 0.75px)) solid ${hov ? T.collapsedBtnBorderHov : T.collapsedBtnBorder}`,
614
614
  transition: "background var(--motion-duration-2) var(--motion-easing-standard), border-color var(--motion-duration-2) var(--motion-easing-standard)",
615
615
  }}
616
616
  >
@@ -646,7 +646,7 @@ export function AppSwitcher({ label: fallbackLabel, icon = "apps", home, apps =
646
646
  background: active ? APP_T.openFill : hov ? APP_T.hoverFill : "transparent",
647
647
  // Drawn inside, as Figma strokes, so 0.75 and 1px do not move the content.
648
648
  border: "none",
649
- boxShadow: `inset 0 0 0 ${active ? "var(--contextual-layout-units-button-borderwidth-selected)" : "var(--contextual-layout-units-button-borderwidth-rest)"} ${active ? APP_T.openStroke : hov ? APP_T.hoverStroke : APP_T.restStroke}, var(--elevation-widget)`,
649
+ boxShadow: `inset 0 0 0 ${active ? "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))"} ${active ? APP_T.openStroke : hov ? APP_T.hoverStroke : APP_T.restStroke}, var(--elevation-widget)`,
650
650
  transition: "background-color var(--motion-duration-3) var(--motion-easing-standard), box-shadow var(--motion-duration-3) var(--motion-easing-standard)" }}>
651
651
  {/* Action Icon: 36 box, 24 wrapper, the glyph at its 16 frame */}
652
652
  <span style={{ width: 36, height: 36, display: "flex", alignItems: "center",
@@ -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 }} />
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@helloimjolopez-pathway/pathway-components",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Pathway Design System React components",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -21,7 +21,8 @@
21
21
  "./badge": "./components/badge/badge.jsx",
22
22
  "./scroll-to-enable": "./components/scroll-to-enable/scroll-to-enable.jsx",
23
23
  "./bottom-sheet": "./components/bottom-sheet/bottom-sheet.jsx",
24
- "./dropdown": "./components/dropdown/dropdown.jsx"
24
+ "./dropdown": "./components/dropdown/dropdown.jsx",
25
+ "./toggle-button": "./components/toggle-button/toggle-button.jsx"
25
26
  },
26
27
  "peerDependencies": {
27
28
  "react": ">=17",