@helloimjolopez-pathway/pathway-components 0.11.1 → 0.12.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.
@@ -160,6 +160,7 @@ const transition = `background ${T.duration} ${T.easing}, color ${T.duration} ${
160
160
  * @param {string} size Small | Medium | Large
161
161
  * @param {string} badgeStyle Outline & Fill | Fill | Outline | Naked
162
162
  * @param {string} leadingIcon Material Symbols ligature; omitted means no icon
163
+ * @param {string} selectedIcon Interactive On: the icon in place of the leading icon (Figma "Check" swap); default "check"
163
164
  * @param {boolean} showTrailingIcon Interactive only: the drill chevron
164
165
  * @param {boolean} selected Interactive, controlled
165
166
  * @param {boolean} defaultSelected Interactive, uncontrolled
@@ -179,6 +180,7 @@ export function Badge({
179
180
  size = "Small",
180
181
  badgeStyle = "Outline & Fill",
181
182
  leadingIcon,
183
+ selectedIcon = "check",
182
184
  trailingIcon,
183
185
  showTrailingIcon = false,
184
186
  selected,
@@ -298,7 +300,7 @@ export function Badge({
298
300
  const fg = disabled ? C("foreground-action-disabled") : on ? a.on.text : a.off.text[st];
299
301
  const stroke = disabled || on || !painted.stroke ? null : a.off.stroke[st];
300
302
  const drill = !!href || showTrailingIcon;
301
- const lead = on ? "check" : leadingIcon;
303
+ const lead = on ? selectedIcon : leadingIcon;
302
304
  const Tag = href ? "a" : "button";
303
305
  const handlers = {
304
306
  onMouseEnter: () => !disabled && setHovered(true),
@@ -19,11 +19,11 @@
19
19
  * BottomSheet.Header 40018716:5340: min height Touch Target/AA,
20
20
  * pad 8 / 2 / 4 / 2 (XTight, XXXXTight,
21
21
  * XXXTight, XXXXTight), gap XXTight
22
- * Back (Show Back) Action Icon Size=Base, `arrow_back`
22
+ * Back (Show Back) Action Icon Size=Medium, `arrow_back`
23
23
  * Container.Title pad left Padding/Base
24
24
  * Title Heading/Local/Base/Semibold,
25
25
  * Foreground/Static/Neutral/Strong
26
- * Close Action Icon Size=Base, `close`
26
+ * Close Action Icon Size=Medium, `close`
27
27
  * Body (slot) pad 0 / XTight / XTight, gap XXTight,
28
28
  * scrolls when taller than the space left
29
29
  * Footer (Show Footer) BottomSheet.Footer 40018716:5354: top border
@@ -160,14 +160,14 @@ export function BottomSheetHeader({ title, titleId, titleRef, showBack = false,
160
160
  display: "flex", alignItems: "center", gap: L.headerGap, minHeight: L.headerMinH, boxSizing: "border-box",
161
161
  padding: `${L.headerPadTop} ${L.headerPadX} ${L.headerPadBottom}`,
162
162
  }}>
163
- {showBack && <ActionIcon name="arrow_back" size="Base" label={backLabel} onClick={onBack} />}
163
+ {showBack && <ActionIcon name="arrow_back" size="Medium" label={backLabel} onClick={onBack} />}
164
164
  <div style={{ flex: 1, minWidth: 0, paddingLeft: L.titleInset }}>
165
165
  <h2 id={titleId} ref={titleRef} tabIndex={-1} style={{
166
166
  margin: 0, outline: "none", color: T.title, ...TYPE.title,
167
167
  overflowWrap: "anywhere", display: "-webkit-box", WebkitLineClamp: 2, WebkitBoxOrient: "vertical", overflow: "hidden",
168
168
  }}>{title}</h2>
169
169
  </div>
170
- <ActionIcon name="close" size="Base" label={closeLabel} onClick={onClose} />
170
+ <ActionIcon name="close" size="Medium" label={closeLabel} onClick={onClose} />
171
171
  </div>
172
172
  );
173
173
  }
@@ -1,43 +1,42 @@
1
1
  /**
2
2
  * ActionIcon: the icon-only control Pathway puts in a header or a toolbar.
3
3
  *
4
- * SOURCE OF TRUTH: Figma set `Action Icon` 40006794:19891, on the
5
- * `↳ ❇️ Action Icon` page. Nine variants on two axes, read and MEASURED
6
- * 2026-10-07. Used 30 times inside the Widget set and twice inside KPI Tiles,
7
- * which makes it the most-nested component in this part of the system and the
8
- * one whose absence from Code Connect mattered most.
4
+ * SOURCE OF TRUTH: Figma set `Action.Icon` 40006794:19891, on the
5
+ * `↳ ❇️ Action Icon` page. Fifteen variants on two axes, State (Rest / Hover /
6
+ * Pressed / Disabled / Focused) x Size (Small / Medium / Large), 2026-11-11. Used 30 times
7
+ * inside the Widget set and twice inside KPI Tiles.
9
8
  *
10
9
  * MEASURED, per Size. The box is the target, `Container.Icon` is the state
11
10
  * layer, and the glyph is the frame size an `<Icon>` takes:
12
11
  *
13
- * Size box Container.Icon glyph
14
- * Small 36x36 24x24 r=8 12
15
- * Base 44x44 28x28 r=8 14
16
- * Large 48x48 32x32 r=8 16
12
+ * Size box, Touch Target token Container.Icon glyph
13
+ * Small 36x36 Accessibility/Touch Target/Desktop Only 24x24 r=8 14
14
+ * Medium 44x44 Accessibility/Touch Target/AA 28x28 r=8 16
15
+ * Large 48x48 Accessibility/Touch Target/AAA 32x32 r=8 20
17
16
  *
18
- * STATE TOKENS, read off all six state variants 2026-10-08.
17
+ * STATE TOKENS, read off all nine variants.
19
18
  *
20
19
  * The state layer's fill:
21
- * Default none
20
+ * Rest none. Figma's Rest variants carry no Container.Icon at all
22
21
  * Hover `Fill/Action/Primary/Subtle/Hover`
23
22
  * Pressed `Fill/Action/Primary/Subtle/Pressed`
24
23
  *
25
24
  * The glyph's colour:
26
- * Default `Foreground/Action/Secondary/Rest`
25
+ * Rest `Foreground/Action/Secondary/Rest`
27
26
  * Hover `Foreground/Action/Secondary/Hover`
28
27
  * Pressed `Foreground/Action/Secondary/Pressed`
29
28
  *
30
- * `State` is Default / Hover / Pressed and is runtime, never a prop.
29
+ * The glyph on Disabled: `Foreground/Action/Disabled`, no layer fill, no hover
30
+ * or press. It keeps `aria-disabled` rather than the native attribute, so Tab
31
+ * still reaches it and its name is still read.
32
+ * Focused (keyboard only): Rest, plus the focus ring around the state layer:
33
+ * `Stroke/FocusRing/Base` at `FocusRing/Width`, hugging the layer (no gap).
34
+ *
35
+ * `State` (Rest / Hover / Pressed / Disabled / Focused) is runtime; only
36
+ * Disabled is a prop, `disabled`, because it is the one a consumer decides.
31
37
  * `Icon` is an INSTANCE_SWAP, which in code is the `name` prop: any Material
32
38
  * Symbols ligature.
33
39
  *
34
- * THE COMPONENT ITSELF BINDS NO FILL, in any of the nine variants, and the
35
- * glyph's fill is unbound too so it inherits. That is why this takes its
36
- * resting colour from `color` on the button and lets the glyph inherit it: the
37
- * design expresses "whatever the surrounding context is" rather than a fixed
38
- * icon colour, and a hard-coded one would break the Widget header, the KPI
39
- * tile and a dark toolbar all differently.
40
- *
41
40
  * WHY IT LIVES HERE rather than in widget.jsx, where it used to. It was a
42
41
  * private helper inside the Widget, so the Figma component had nothing
43
42
  * importable to point at and the KPI Tile could not reuse it. The Widget now
@@ -52,21 +51,18 @@ injectFocusRing();
52
51
  const C = (n) => `var(--semantic-color-${n})`;
53
52
  const U = (n) => `var(--semantic-layout-units-${n})`;
54
53
  const M = (n) => `var(--motion-${n})`;
54
+ const CL = (n) => `var(--contextual-layout-units-${n})`;
55
55
 
56
- /* Read off the Action Icon set, every variant, 2026-10-12. `box` is the button,
57
- `layer` is the state-layer frame the hover and pressed fills go on, `glyph` is
58
- the nested Material Symbols instance.
59
-
60
- THE GLYPHS WERE EVERY ONE 2px TOO SMALL. This read 12 / 14 / 16, which is the
61
- sequence one step down from the real one, so every action icon in the system
62
- rendered small inside a correctly sized target: the Widget header's 16px
63
- ellipsis drew at 14. The three state-layer sizes were right, which is why it
64
- survived a measurement pass that compared targets and layers and never looked
65
- at the glyph. Figma's nested instance is 14 at Small, 16 at Base, 18 at Large. */
56
+ /* Per Size, read off every variant on 2026-11-11. `box` is the button, bound in
57
+ Figma to the Touch Target tokens; `layer` is the state-layer frame the hover
58
+ and pressed fills go on; `glyph` is the nested Material Symbols instance. The
59
+ numbers are kept beside the tokens for the stories and for anything that has
60
+ to do arithmetic with the box. Large's glyph is 20, not the 18 it was. */
61
+ const TT = (n) => `var(--semantic-layout-units-accessibility-touch-target-${n})`;
66
62
  export const ACTION_ICON_SIZES = {
67
- Small: { box: 36, layer: 24, glyph: 14 },
68
- Base: { box: 44, layer: 28, glyph: 16 },
69
- Large: { box: 48, layer: 32, glyph: 18 },
63
+ Small: { box: 36, boxW: TT("desktop-only-width"), boxH: TT("desktop-only-height"), layer: 24, glyph: 14 },
64
+ Medium: { box: 44, boxW: TT("aa-width"), boxH: TT("aa-height"), layer: 28, glyph: 16 },
65
+ Large: { box: 48, boxW: TT("aaa-width"), boxH: TT("aaa-height"), layer: 32, glyph: 20 },
70
66
  };
71
67
 
72
68
  export const T = {
@@ -81,11 +77,15 @@ export const T = {
81
77
  the hover state is the only thing the layer is for. */
82
78
  layerHover: C("fill-action-primary-subtle-hover"),
83
79
  layerPress: C("fill-action-primary-subtle-pressed"),
80
+ disabled: C("foreground-action-disabled"),
81
+ focusOutline: `${CL("focusring-width")} solid ${C("stroke-focusring-base")}`,
84
82
  };
85
83
 
86
84
  /**
87
85
  * @param {string} name Material Symbols ligature, Figma's `Icon` swap.
88
- * @param {"Small"|"Base"|"Large"} size Figma's Size axis.
86
+ * @param {"Small"|"Medium"|"Large"} size Figma's Size axis, same words.
87
+ * Small is Desktop Only (36): dense desktop surfaces
88
+ * only. Medium meets AA (44). Large meets AAA (48).
89
89
  * @param {string} label Required: an icon-only control with no accessible
90
90
  * name is unusable, so this is not optional.
91
91
  * @param {0|1} fill FILL axis. The Widget header is FILL 0.
@@ -99,6 +99,11 @@ export const T = {
99
99
  * dark Secondary glyph, which is right on a light page and
100
100
  * wrong on the navy TopNav. SideNav.Control (40007082:7703)
101
101
  * binds the Ghost ladder instead.
102
+ * @param {boolean} disabled Figma's Disabled state. aria-disabled, never the
103
+ * native attribute: it stays in the tab order.
104
+ * @param {"hover"|"pressed"|"focused"} forceState Stories and demos only: draws
105
+ * a state without the pointer or keyboard, so the state
106
+ * matrix can show every variant Figma has at once.
102
107
  */
103
108
  export function ActionIcon({
104
109
  name,
@@ -107,26 +112,29 @@ export function ActionIcon({
107
112
  fill = 0,
108
113
  color,
109
114
  states,
115
+ forceState,
116
+ disabled = false,
110
117
  onClick,
111
- disabled,
112
118
  className = "",
113
119
  style,
114
120
  ...rest
115
121
  }) {
116
122
  const noMotion = useReducedMotion();
117
- const [hov, setHov] = useState(false);
118
- const [press, setPress] = useState(false);
123
+ const [hovRaw, setHov] = useState(false);
124
+ const [pressRaw, setPress] = useState(false);
125
+ const hov = !disabled && (hovRaw || forceState === "hover");
126
+ const press = !disabled && (pressRaw || forceState === "pressed");
119
127
  const s = ACTION_ICON_SIZES[size] || ACTION_ICON_SIZES.Small;
120
128
  const st = { ...T, ...states };
121
- const fg = disabled ? T.rest : press ? st.pressed : hov ? st.hover : (color || T.rest);
129
+ const fg = disabled ? T.disabled : press ? st.pressed : hov ? st.hover : (color || T.rest);
122
130
 
123
131
  return (
124
132
  <button
125
133
  type="button"
126
134
  aria-label={label}
127
135
  title={label}
128
- disabled={disabled}
129
- onClick={onClick}
136
+ aria-disabled={disabled || undefined}
137
+ onClick={disabled ? undefined : onClick}
130
138
  onMouseEnter={() => setHov(true)}
131
139
  onMouseLeave={() => { setHov(false); setPress(false); }}
132
140
  onMouseDown={() => setPress(true)}
@@ -136,10 +144,9 @@ export function ActionIcon({
136
144
  data-size={size}
137
145
  style={{
138
146
  display: "inline-flex", alignItems: "center", justifyContent: "center",
139
- width: s.box, height: s.box, flexShrink: 0, boxSizing: "border-box",
140
- background: "transparent", border: "none", padding: 0,
141
- cursor: disabled ? "default" : "pointer",
142
- opacity: disabled ? 0.5 : 1,
147
+ width: s.boxW, height: s.boxH, flexShrink: 0, boxSizing: "border-box",
148
+ background: "transparent", border: "none", padding: 0, outline: "none",
149
+ cursor: disabled ? "not-allowed" : "pointer",
143
150
  color: fg,
144
151
  ...style,
145
152
  }}
@@ -153,7 +160,12 @@ export function ActionIcon({
153
160
  display: "inline-flex", alignItems: "center", justifyContent: "center",
154
161
  width: s.layer, height: s.layer, borderRadius: U("cornerradius-base"),
155
162
  background: press ? st.layerPress : hov ? st.layerHover : "transparent",
156
- transition: noMotion ? "none" : `background ${M("duration-2")} ${M("easing-standard")}`,
163
+ /* Focused sits AROUND THE STATE LAYER, not the target: Jo, 2026-11-11.
164
+ Keyboard focus draws it through FOCUS_RING_LAYER (hugging the layer,
165
+ no gap); forceState draws the same ring for stories and demos. */
166
+ outline: !disabled && forceState === "focused" ? T.focusOutline : undefined,
167
+ outlineOffset: !disabled && forceState === "focused" ? 0 : undefined,
168
+ transition: noMotion ? "none" : `background ${M("duration-2")} ${M("easing-standard")}, outline ${M("duration-2")} ${M("easing-standard")}`,
157
169
  }}>
158
170
  <Icon name={name} size={s.glyph} fill={fill} style={{
159
171
  transition: noMotion ? "none" : `color ${M("duration-2")} ${M("easing-standard")}`,
@@ -703,7 +703,7 @@ export function PopoverMenu({
703
703
  } else if (type === "avatar") {
704
704
  trigger = <button type="button" className="pw-menu-trigger__avatar" {...triggerProps} aria-label={label}>{initials}</button>;
705
705
  } else if (iconStyle === "action") {
706
- trigger = <ActionIcon name={icon} size="Base" label={label} {...triggerProps} />;
706
+ trigger = <ActionIcon name={icon} size="Medium" label={label} {...triggerProps} />;
707
707
  } else {
708
708
  trigger = (
709
709
  <button type="button" className="pw-menu-trigger__icon" {...triggerProps} aria-label={label} title={label}
@@ -221,7 +221,7 @@ export const L = {
221
221
  chevronGlyph: 12,
222
222
  modChevronBox: 20,
223
223
  orgChevronBox: 24,
224
- // Action Icon Size=Base: 16 glyph in a 28 Container.Icon. The glyph was 14,
224
+ // Action Icon Size=Medium: 16 glyph in a 28 Container.Icon. The glyph was 14,
225
225
  // which also put it below the icon font's smallest optical size.
226
226
  actionIconBox: 28, actionIconGlyph: 16,
227
227
  // Figma's Action Icon is 44, not the bar's 48. It is the one control on the
@@ -336,7 +336,7 @@ export const DEFAULT_MODULES = MODULES.map((id) => ({ id, label: MODULE_LABELS[i
336
336
  * TopNav.Actions, Figma 40007082:7313. A SLOT (Figma property "Slot.Actions"),
337
337
  * not a fixed pair of bells: a product puts whatever action icons it needs in
338
338
  * it, one bell, or a bell, a cog and a help icon. Each item renders as Figma's
339
- * nested Action Icon at Size=Base.
339
+ * nested Action Icon at Size=Medium.
340
340
  *
341
341
  * actions Array<{ icon, label, onClick }>. `icon` is a Material Symbols
342
342
  * ligature, `label` the accessible name. Defaults to the two
@@ -346,7 +346,7 @@ export const DEFAULT_MODULES = MODULES.map((id) => ({ id, label: MODULE_LABELS[i
346
346
  * ("More actions"), which is what Figma's mobile variant shows. One action on
347
347
  * its own stays as itself.
348
348
  *
349
- * Action Icon Size=Base, read 2026-10-30 and 2026-11-02:
349
+ * Action Icon Size=Medium, read 2026-10-30 and 2026-11-02:
350
350
  * target accessibility-touch-target-aa (44)
351
351
  * Container.Icon 28, cornerradius-base: rest none, hover
352
352
  * Fill/Action/Ghost/Hover, pressed Fill/Action/Ghost/Pressed