@helloimjolopez-pathway/pathway-components 0.0.0-stage → 0.1.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/_shared/use-reduced-motion.jsx +49 -0
- package/components/button/button.jsx +512 -0
- package/components/icon/action-icon.jsx +155 -0
- package/components/icon/display-icon.jsx +137 -0
- package/components/icon/featured-icon.jsx +162 -0
- package/components/icon/icon.jsx +154 -0
- package/components/module-icon/module-icon.jsx +387 -0
- package/components/org-switcher/org-switcher.jsx +604 -0
- package/components/page-template/page-template.jsx +147 -0
- package/components/scrollbar/scrollbar.jsx +232 -0
- package/components/search/search.jsx +689 -0
- package/components/sidenav/sidenav.jsx +951 -0
- package/components/top-nav/top-nav.jsx +1003 -0
- package/package.json +26 -4
- package/README.md +0 -3
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ActionIcon: the icon-only control Pathway puts in a header or a toolbar.
|
|
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.
|
|
9
|
+
*
|
|
10
|
+
* MEASURED, per Size. The box is the target, `Container.Icon` is the state
|
|
11
|
+
* layer, and the glyph is the frame size an `<Icon>` takes:
|
|
12
|
+
*
|
|
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
|
|
17
|
+
*
|
|
18
|
+
* STATE TOKENS, read off all six state variants 2026-10-08.
|
|
19
|
+
*
|
|
20
|
+
* The state layer's fill:
|
|
21
|
+
* Default none
|
|
22
|
+
* Hover `Fill/Action/Primary/Subtle/Hover`
|
|
23
|
+
* Pressed `Fill/Action/Primary/Subtle/Pressed`
|
|
24
|
+
*
|
|
25
|
+
* The glyph's colour:
|
|
26
|
+
* Default `Foreground/Action/Secondary/Rest`
|
|
27
|
+
* Hover `Foreground/Action/Secondary/Hover`
|
|
28
|
+
* Pressed `Foreground/Action/Secondary/Pressed`
|
|
29
|
+
*
|
|
30
|
+
* `State` is Default / Hover / Pressed and is runtime, never a prop.
|
|
31
|
+
* `Icon` is an INSTANCE_SWAP, which in code is the `name` prop: any Material
|
|
32
|
+
* Symbols ligature.
|
|
33
|
+
*
|
|
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
|
+
* WHY IT LIVES HERE rather than in widget.jsx, where it used to. It was a
|
|
42
|
+
* private helper inside the Widget, so the Figma component had nothing
|
|
43
|
+
* importable to point at and the KPI Tile could not reuse it. The Widget now
|
|
44
|
+
* imports it.
|
|
45
|
+
*/
|
|
46
|
+
import React, { useState } from "react";
|
|
47
|
+
import { Icon } from "./icon.jsx";
|
|
48
|
+
import { useReducedMotion } from "../_shared/use-reduced-motion.jsx";
|
|
49
|
+
|
|
50
|
+
const C = (n) => `var(--semantic-color-${n})`;
|
|
51
|
+
const U = (n) => `var(--semantic-layout-units-${n})`;
|
|
52
|
+
const M = (n) => `var(--motion-${n})`;
|
|
53
|
+
|
|
54
|
+
/* Read off the Action Icon set, every variant, 2026-10-12. `box` is the button,
|
|
55
|
+
`layer` is the state-layer frame the hover and pressed fills go on, `glyph` is
|
|
56
|
+
the nested Material Symbols instance.
|
|
57
|
+
|
|
58
|
+
THE GLYPHS WERE EVERY ONE 2px TOO SMALL. This read 12 / 14 / 16, which is the
|
|
59
|
+
sequence one step down from the real one, so every action icon in the system
|
|
60
|
+
rendered small inside a correctly sized target: the Widget header's 16px
|
|
61
|
+
ellipsis drew at 14. The three state-layer sizes were right, which is why it
|
|
62
|
+
survived a measurement pass that compared targets and layers and never looked
|
|
63
|
+
at the glyph. Figma's nested instance is 14 at Small, 16 at Base, 18 at Large. */
|
|
64
|
+
export const ACTION_ICON_SIZES = {
|
|
65
|
+
Small: { box: 36, layer: 24, glyph: 14 },
|
|
66
|
+
Base: { box: 44, layer: 28, glyph: 16 },
|
|
67
|
+
Large: { box: 48, layer: 32, glyph: 18 },
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export const T = {
|
|
71
|
+
rest: C("foreground-action-secondary-rest"),
|
|
72
|
+
hover: C("foreground-action-secondary-hover"),
|
|
73
|
+
pressed: C("foreground-action-secondary-pressed"),
|
|
74
|
+
/* THE STATE LAYER IS ACTION/PRIMARY/SUBTLE, NOT ACTION/SECONDARY.
|
|
75
|
+
Read off the set's six state variants on 2026-10-08: Hover binds
|
|
76
|
+
Fill/Action/Primary/Subtle/Hover and Pressed binds /Pressed. This file used
|
|
77
|
+
the SECONDARY family, which is a neutral grey, where the design is a light
|
|
78
|
+
blue. That is the single most visible token error in the component, because
|
|
79
|
+
the hover state is the only thing the layer is for. */
|
|
80
|
+
layerHover: C("fill-action-primary-subtle-hover"),
|
|
81
|
+
layerPress: C("fill-action-primary-subtle-pressed"),
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* @param {string} name Material Symbols ligature, Figma's `Icon` swap.
|
|
86
|
+
* @param {"Small"|"Base"|"Large"} size Figma's Size axis.
|
|
87
|
+
* @param {string} label Required: an icon-only control with no accessible
|
|
88
|
+
* name is unusable, so this is not optional.
|
|
89
|
+
* @param {0|1} fill FILL axis. The Widget header is FILL 0.
|
|
90
|
+
* @param {string} color Override the resting colour. Omit on a light surface;
|
|
91
|
+
* pass one on a dark or on-brand surface, where Ghost
|
|
92
|
+
* is the right family.
|
|
93
|
+
*/
|
|
94
|
+
export function ActionIcon({
|
|
95
|
+
name,
|
|
96
|
+
size = "Small",
|
|
97
|
+
label,
|
|
98
|
+
fill = 0,
|
|
99
|
+
color,
|
|
100
|
+
onClick,
|
|
101
|
+
disabled,
|
|
102
|
+
className = "",
|
|
103
|
+
style,
|
|
104
|
+
...rest
|
|
105
|
+
}) {
|
|
106
|
+
const noMotion = useReducedMotion();
|
|
107
|
+
const [hov, setHov] = useState(false);
|
|
108
|
+
const [press, setPress] = useState(false);
|
|
109
|
+
const s = ACTION_ICON_SIZES[size] || ACTION_ICON_SIZES.Small;
|
|
110
|
+
const fg = disabled ? T.rest : press ? T.pressed : hov ? T.hover : (color || T.rest);
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<button
|
|
114
|
+
type="button"
|
|
115
|
+
aria-label={label}
|
|
116
|
+
title={label}
|
|
117
|
+
disabled={disabled}
|
|
118
|
+
onClick={onClick}
|
|
119
|
+
onMouseEnter={() => setHov(true)}
|
|
120
|
+
onMouseLeave={() => { setHov(false); setPress(false); }}
|
|
121
|
+
onMouseDown={() => setPress(true)}
|
|
122
|
+
onMouseUp={() => setPress(false)}
|
|
123
|
+
onBlur={() => setPress(false)}
|
|
124
|
+
className={`pw-action-icon ${className}`}
|
|
125
|
+
data-size={size}
|
|
126
|
+
style={{
|
|
127
|
+
display: "inline-flex", alignItems: "center", justifyContent: "center",
|
|
128
|
+
width: s.box, height: s.box, flexShrink: 0, boxSizing: "border-box",
|
|
129
|
+
background: "transparent", border: "none", padding: 0,
|
|
130
|
+
cursor: disabled ? "default" : "pointer",
|
|
131
|
+
opacity: disabled ? 0.5 : 1,
|
|
132
|
+
color: fg,
|
|
133
|
+
...style,
|
|
134
|
+
}}
|
|
135
|
+
{...rest}
|
|
136
|
+
>
|
|
137
|
+
{/* Container.Icon is the state layer, and it is SMALLER than the target.
|
|
138
|
+
The fill belongs on this box, not on the button: painting the target
|
|
139
|
+
gives a hover area visibly larger than the one in the design, which
|
|
140
|
+
is the bug the Widget header shipped with once. */}
|
|
141
|
+
<span style={{
|
|
142
|
+
display: "inline-flex", alignItems: "center", justifyContent: "center",
|
|
143
|
+
width: s.layer, height: s.layer, borderRadius: U("cornerradius-base"),
|
|
144
|
+
background: press ? T.layerPress : hov ? T.layerHover : "transparent",
|
|
145
|
+
transition: noMotion ? "none" : `background ${M("duration-2")} ${M("easing-standard")}`,
|
|
146
|
+
}}>
|
|
147
|
+
<Icon name={name} size={s.glyph} fill={fill} style={{
|
|
148
|
+
transition: noMotion ? "none" : `color ${M("duration-2")} ${M("easing-standard")}`,
|
|
149
|
+
}} />
|
|
150
|
+
</span>
|
|
151
|
+
</button>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export default ActionIcon;
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DisplayIcon: a decorative icon in a tinted circle, used by alerts, empty
|
|
3
|
+
* states and the KPI tile's featured icon slot.
|
|
4
|
+
*
|
|
5
|
+
* SOURCE OF TRUTH: Figma set `DisplayIcon` 40006522:26547 on the
|
|
6
|
+
* `↳ ❇️ Display Icon` page. 21 variants on Size x Color, plus an
|
|
7
|
+
* INSTANCE_SWAP for the glyph, read and MEASURED 2026-10-07.
|
|
8
|
+
*
|
|
9
|
+
* MEASURED
|
|
10
|
+
* Size box glyph
|
|
11
|
+
* Small 24x24 12
|
|
12
|
+
* Medium 32x32 16
|
|
13
|
+
* Large 40x40 20
|
|
14
|
+
*
|
|
15
|
+
* Color Accent | Positive | Negative | Danger | Neutral | Info | Alert
|
|
16
|
+
*
|
|
17
|
+
* THE COLOUR AXIS MAPS ONTO THE MEANING-NAMED TONE GROUPS, not onto hue names,
|
|
18
|
+
* which is what CLAUDE.md §2.0 requires. `Danger` and `Negative` are both in
|
|
19
|
+
* the Figma axis and are different tones in the token set: Negative is Red and
|
|
20
|
+
* Severe is Orange, so Danger takes Severe. `Accent` has no single meaning, so
|
|
21
|
+
* it takes Brand rather than one of the four decorative accents, because a
|
|
22
|
+
* display icon on an alert is not decoration.
|
|
23
|
+
*
|
|
24
|
+
* Each tone draws its SUBTLE fill with its ON SUBTLE foreground, which is the
|
|
25
|
+
* pairing the token set guarantees and `check-fill-foreground-pairs` asserts.
|
|
26
|
+
* Reaching for a Strong fill here would put tone text on a tone block.
|
|
27
|
+
*
|
|
28
|
+
* THE TOKENS BELOW ARE READ BACK FROM FIGMA, not derived from the names.
|
|
29
|
+
*
|
|
30
|
+
* What was there before, and what it took to find it: the variant itself has no
|
|
31
|
+
* fill and the nested glyph instance has no fill, so a reader checking those two
|
|
32
|
+
* nodes concludes the Color axis is unbound. It is not: the bindings live on the
|
|
33
|
+
* `Container` frame and on the `_shape` vector INSIDE the glyph instance. A
|
|
34
|
+
* first pass reported "no tokens at all", which was wrong.
|
|
35
|
+
*
|
|
36
|
+
* The real defects, found by reading all 21 variants on 2026-10-08 and fixed in
|
|
37
|
+
* the file the same day:
|
|
38
|
+
*
|
|
39
|
+
* - Only 4 of 21 glyphs were bound. The other 17 were raw fills.
|
|
40
|
+
* - Two of those 4 used HUE-NAMED tokens, `Foreground/Static/Amethyst/Bold`
|
|
41
|
+
* and `Foreground/Static/Green/Bold`, which the semantic tier forbids and
|
|
42
|
+
* which are not even in the local variable set any more.
|
|
43
|
+
* - `Neutral` drew `Fill/Action/Secondary/Hover`: an ACTION token on a
|
|
44
|
+
* decorative element that has no states.
|
|
45
|
+
* - `Info` drew `Fill/Static/Brand/Faint`, and Faint has no paired On Subtle
|
|
46
|
+
* foreground, so the pairing the token set guarantees did not exist for it.
|
|
47
|
+
* - Every Container carried a paint STYLE as well as a variable, which is two
|
|
48
|
+
* sources of truth for one colour. The styles were cleared.
|
|
49
|
+
*/
|
|
50
|
+
import React from "react";
|
|
51
|
+
import { Icon } from "./icon.jsx";
|
|
52
|
+
|
|
53
|
+
const C = (n) => `var(--semantic-color-${n})`;
|
|
54
|
+
|
|
55
|
+
const U = (n) => `var(--semantic-layout-units-${n})`;
|
|
56
|
+
|
|
57
|
+
export const L = {
|
|
58
|
+
// Figma: Container cornerRadius 8 on all 21 variants.
|
|
59
|
+
radius: U("cornerradius-base"),
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export const DISPLAY_ICON_SIZES = {
|
|
63
|
+
Small: { box: 24, glyph: 12 },
|
|
64
|
+
Medium: { box: 32, glyph: 16 },
|
|
65
|
+
Large: { box: 40, glyph: 20 },
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Figma's Color axis, read back from the file after binding on 2026-10-08.
|
|
70
|
+
*
|
|
71
|
+
* EACH VALUE KEEPS THE HUE IT ALREADY RENDERED. The names are not a clean map
|
|
72
|
+
* onto the tone groups and that is the file's own history, not something to
|
|
73
|
+
* tidy here: `Accent` resolves to INFO (amethyst) and `Info` resolves to BRAND.
|
|
74
|
+
* Repointing either would repaint a variant rather than fix a mapping, so the
|
|
75
|
+
* binding kept the hue and fixed only the tier and the pairing.
|
|
76
|
+
*
|
|
77
|
+
* An earlier version of this file guessed Accent to Brand and Info to Info,
|
|
78
|
+
* which is those two swapped, so a consumer asking for Accent got blue where
|
|
79
|
+
* the design is amethyst.
|
|
80
|
+
*/
|
|
81
|
+
export const DISPLAY_ICON_COLORS = {
|
|
82
|
+
// Accent is amethyst, which is the Info tone in the token set.
|
|
83
|
+
Accent: { fill: C("fill-static-info-subtle"), fg: C("foreground-static-info-on-subtle") },
|
|
84
|
+
Positive: { fill: C("fill-static-positive-subtle"), fg: C("foreground-static-positive-on-subtle") },
|
|
85
|
+
Negative: { fill: C("fill-static-negative-subtle"), fg: C("foreground-static-negative-on-subtle") },
|
|
86
|
+
// Danger is Severe (orange), a different tone from Negative (red). Both are
|
|
87
|
+
// in the Figma axis, so they must stay different here too.
|
|
88
|
+
Danger: { fill: C("fill-static-severe-subtle"), fg: C("foreground-static-severe-on-subtle") },
|
|
89
|
+
Alert: { fill: C("fill-static-attention-subtle"), fg: C("foreground-static-attention-on-subtle") },
|
|
90
|
+
// Neutral has no On Subtle rung, so its foreground is Base.
|
|
91
|
+
Neutral: { fill: C("fill-static-neutral-subtle"), fg: C("foreground-static-neutral-base") },
|
|
92
|
+
// Info is the BRAND blue in this component, not the Info tone.
|
|
93
|
+
Info: { fill: C("fill-static-brand-subtle"), fg: C("foreground-static-brand-on-subtle") },
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* @param {string} name Material Symbols ligature, Figma's `IconInstance` swap.
|
|
98
|
+
* @param {"Small"|"Medium"|"Large"} size
|
|
99
|
+
* @param {keyof DISPLAY_ICON_COLORS} color Figma's Color axis.
|
|
100
|
+
* @param {string} label Omit for a decorative icon, which is the common case:
|
|
101
|
+
* a display icon almost always sits beside a heading
|
|
102
|
+
* that already says what it means.
|
|
103
|
+
*/
|
|
104
|
+
export function DisplayIcon({
|
|
105
|
+
name,
|
|
106
|
+
size = "Small",
|
|
107
|
+
color = "Accent",
|
|
108
|
+
fill = 1,
|
|
109
|
+
label,
|
|
110
|
+
className = "",
|
|
111
|
+
style,
|
|
112
|
+
...rest
|
|
113
|
+
}) {
|
|
114
|
+
const s = DISPLAY_ICON_SIZES[size] || DISPLAY_ICON_SIZES.Small;
|
|
115
|
+
const tone = DISPLAY_ICON_COLORS[color] || DISPLAY_ICON_COLORS.Accent;
|
|
116
|
+
return (
|
|
117
|
+
<span
|
|
118
|
+
className={`pw-display-icon ${className}`}
|
|
119
|
+
data-size={size}
|
|
120
|
+
data-color={color}
|
|
121
|
+
style={{
|
|
122
|
+
display: "inline-flex", alignItems: "center", justifyContent: "center",
|
|
123
|
+
width: s.box, height: s.box, flexShrink: 0,
|
|
124
|
+
/* r=8 ON EVERY VARIANT, a rounded square. This drew a full circle, which
|
|
125
|
+
is a different shape: Figma's Container corner radius is 8 at all
|
|
126
|
+
three sizes, and only the Featured icon's Light outline type is round. */
|
|
127
|
+
borderRadius: L.radius,
|
|
128
|
+
background: tone.fill, color: tone.fg, ...style,
|
|
129
|
+
}}
|
|
130
|
+
{...rest}
|
|
131
|
+
>
|
|
132
|
+
<Icon name={name} size={s.glyph} fill={fill} label={label} />
|
|
133
|
+
</span>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export default DisplayIcon;
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FeaturedIcon: the large icon badge a KPI tile puts above its number.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS. The KPI Tiles set nests a FOREIGN component here: `Featured
|
|
5
|
+
* icon` 40003806:3550, a remote library component from Untitled UI, the same
|
|
6
|
+
* source as the `↳ Select` page in CLAUDE.md §8.1. Eighty-four variants of it,
|
|
7
|
+
* ten instances inside a Pathway component, none of them ours and none of them
|
|
8
|
+
* mappable from this repo because a Code Connect mapping publishes into the
|
|
9
|
+
* file that owns the component.
|
|
10
|
+
*
|
|
11
|
+
* THE FOREIGN COMPONENT WAS ALREADY USING PATHWAY'S COLOURS, just not bound to
|
|
12
|
+
* them. Every one of the seven raw hexes in the four variants the KPI Tile uses
|
|
13
|
+
* matches an existing Pathway semantic token EXACTLY, distance 0 in RGB:
|
|
14
|
+
*
|
|
15
|
+
* #dff6e2 Fill/Static/Positive/Subtle
|
|
16
|
+
* #dcd9ef Fill/Static/Info/Subtle
|
|
17
|
+
* #353063 Foreground/Static/Info/On Subtle
|
|
18
|
+
* #ffffff Fill/Surface/Elevated
|
|
19
|
+
* #e6e2dc Stroke/Static/Neutral/Base
|
|
20
|
+
* #313131 Foreground/Static/Neutral/Base
|
|
21
|
+
* #001f00 Foreground/Static/Positive/On Strong
|
|
22
|
+
*
|
|
23
|
+
* So nothing here is a redesign. It is the same drawing on the tokens it was
|
|
24
|
+
* already the colour of.
|
|
25
|
+
*
|
|
26
|
+
* MEASURED, from the four variants the KPI Tile actually nests:
|
|
27
|
+
*
|
|
28
|
+
* Type=Light outline r=28, so a circle
|
|
29
|
+
* lg 48x48, glyph 24
|
|
30
|
+
* fill and stroke are THE SAME HEX at sw=8, so the ring is invisible and
|
|
31
|
+
* the shape is a flat tinted circle. Reproduced as a solid circle, because
|
|
32
|
+
* an 8px stroke in the fill's own colour is decoration that draws nothing.
|
|
33
|
+
*
|
|
34
|
+
* Type=Modern r=8, a rounded square. NOT tone-coloured:
|
|
35
|
+
* lg 48x48, glyph 24
|
|
36
|
+
* md 40x40, glyph 20
|
|
37
|
+
* Fill/Surface/Elevated, 1px Stroke/Static/Neutral/Base, inside align,
|
|
38
|
+
* plus a shadow. Its glyph is Foreground/Static/Neutral/Base.
|
|
39
|
+
*
|
|
40
|
+
* ONE DEPARTURE AND ONE GAP, both deliberate:
|
|
41
|
+
*
|
|
42
|
+
* - The Positive glyph takes `On Subtle` (#0d351b) rather than the `On
|
|
43
|
+
* Strong` (#001f00) the foreign component used. On Strong is the
|
|
44
|
+
* foreground for a SOLID fill and this glyph sits on a Subtle one, so the
|
|
45
|
+
* exact-hex match would have been a pairing violation. On Subtle measures
|
|
46
|
+
* 11.94:1 on that tint against On Strong's 15.4:1, so both clear AAA and
|
|
47
|
+
* the difference is invisible at 24px. This also makes Positive consistent
|
|
48
|
+
* with the Info variant, which was already an exact match AND the correct
|
|
49
|
+
* pairing.
|
|
50
|
+
*
|
|
51
|
+
* - Figma's Modern shadow is `0 1 2 rgba(10,13,18,0.05)` plus two inner
|
|
52
|
+
* shadows. Pathway's smallest elevation token is `--elevation-widget`,
|
|
53
|
+
* `0 2px 8px -2px`, so there is no exact match and the inner shadows have
|
|
54
|
+
* no token at all. Rather than invent one, this uses the nearest existing
|
|
55
|
+
* token and the gap is recorded in the spec. The inner shadows are dropped:
|
|
56
|
+
* they are an Untitled UI flourish, not a Pathway treatment.
|
|
57
|
+
*/
|
|
58
|
+
import React from "react";
|
|
59
|
+
import { Icon } from "./icon.jsx";
|
|
60
|
+
|
|
61
|
+
const C = (n) => `var(--semantic-color-${n})`;
|
|
62
|
+
const U = (n) => `var(--semantic-layout-units-${n})`;
|
|
63
|
+
|
|
64
|
+
export const FEATURED_ICON_SIZES = {
|
|
65
|
+
md: { box: 40, glyph: 20 },
|
|
66
|
+
lg: { box: 48, glyph: 24 },
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The tones, on the same pairing every Pathway tone group guarantees: a Subtle
|
|
71
|
+
* fill with its On Subtle foreground. Matched to DisplayIcon's table so the two
|
|
72
|
+
* cannot disagree, including that `Accent` is amethyst (Info) and `Info` is
|
|
73
|
+
* brand, which is the file's own history.
|
|
74
|
+
*/
|
|
75
|
+
export const FEATURED_ICON_COLORS = {
|
|
76
|
+
Accent: { fill: C("fill-static-info-subtle"), fg: C("foreground-static-info-on-subtle") },
|
|
77
|
+
Positive: { fill: C("fill-static-positive-subtle"), fg: C("foreground-static-positive-on-subtle") },
|
|
78
|
+
Negative: { fill: C("fill-static-negative-subtle"), fg: C("foreground-static-negative-on-subtle") },
|
|
79
|
+
Danger: { fill: C("fill-static-severe-subtle"), fg: C("foreground-static-severe-on-subtle") },
|
|
80
|
+
Alert: { fill: C("fill-static-attention-subtle"), fg: C("foreground-static-attention-on-subtle") },
|
|
81
|
+
Neutral: { fill: C("fill-static-neutral-subtle"), fg: C("foreground-static-neutral-base") },
|
|
82
|
+
Info: { fill: C("fill-static-brand-subtle"), fg: C("foreground-static-brand-on-subtle") },
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export const FEATURED_ICON_TYPES = ["outline", "modern"];
|
|
86
|
+
|
|
87
|
+
export const T = {
|
|
88
|
+
chipFill: C("fill-surface-elevated"),
|
|
89
|
+
chipBorder: C("stroke-static-neutral-base"),
|
|
90
|
+
chipGlyph: C("foreground-static-neutral-base"),
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
export const L = {
|
|
94
|
+
// Light outline is r=28 on a 48 box, which is past half the width, so it is a
|
|
95
|
+
// circle rather than a specific radius. CornerRadius/Full says that.
|
|
96
|
+
circle: U("cornerradius-full"),
|
|
97
|
+
// Modern is r=8 at both sizes.
|
|
98
|
+
chip: U("cornerradius-base"),
|
|
99
|
+
border: "var(--semantic-layout-units-borderwidth-base)",
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* @param {string} name Material Symbols ligature.
|
|
104
|
+
* @param {"md"|"lg"} size Figma's Size axis, the two values the design uses.
|
|
105
|
+
* @param {"outline"|"modern"} type Figma's Type axis. `outline` is the tinted
|
|
106
|
+
* circle and takes `color`; `modern` is the white chip
|
|
107
|
+
* and ignores it, because it is not tone-coloured.
|
|
108
|
+
* @param {keyof FEATURED_ICON_COLORS} color Only meaningful for `outline`.
|
|
109
|
+
* @param {string} glyphColor Override the glyph colour with a semantic token's
|
|
110
|
+
* var(). Needed because Figma is NOT consistent here: the
|
|
111
|
+
* KPI Tile's Icon 01 binds its glyph to
|
|
112
|
+
* Foreground/Static/Positive/On STRONG while Icon 02 binds
|
|
113
|
+
* Info/On Subtle, for the same Light outline type. Rather
|
|
114
|
+
* than silently normalise one of them, the component
|
|
115
|
+
* defaults to the correct On Subtle pairing and the call
|
|
116
|
+
* site can say when the file differs.
|
|
117
|
+
* @param {string} label Omit for a decorative badge, which is the common case:
|
|
118
|
+
* a featured icon sits above a heading that already says
|
|
119
|
+
* what it means.
|
|
120
|
+
*/
|
|
121
|
+
export function FeaturedIcon({
|
|
122
|
+
name = "trending_up",
|
|
123
|
+
size = "lg",
|
|
124
|
+
type = "outline",
|
|
125
|
+
color = "Positive",
|
|
126
|
+
glyphColor,
|
|
127
|
+
fill = 1,
|
|
128
|
+
label,
|
|
129
|
+
className = "",
|
|
130
|
+
style,
|
|
131
|
+
...rest
|
|
132
|
+
}) {
|
|
133
|
+
const s = FEATURED_ICON_SIZES[size] || FEATURED_ICON_SIZES.lg;
|
|
134
|
+
const modern = type === "modern";
|
|
135
|
+
const tone = FEATURED_ICON_COLORS[color] || FEATURED_ICON_COLORS.Positive;
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<span
|
|
139
|
+
className={`pw-featured-icon ${className}`}
|
|
140
|
+
data-size={size}
|
|
141
|
+
data-type={type}
|
|
142
|
+
data-color={modern ? "Neutral" : color}
|
|
143
|
+
style={{
|
|
144
|
+
display: "inline-flex", alignItems: "center", justifyContent: "center",
|
|
145
|
+
width: s.box, height: s.box, flexShrink: 0, boxSizing: "border-box",
|
|
146
|
+
borderRadius: modern ? L.chip : L.circle,
|
|
147
|
+
background: modern ? T.chipFill : tone.fill,
|
|
148
|
+
color: glyphColor || (modern ? T.chipGlyph : tone.fg),
|
|
149
|
+
// Modern's 1px border is INSIDE-aligned in Figma, which border-box
|
|
150
|
+
// reproduces: the chip stays 48, the border eats into it.
|
|
151
|
+
border: modern ? `${L.border} solid ${T.chipBorder}` : "none",
|
|
152
|
+
boxShadow: modern ? "var(--elevation-widget)" : "none",
|
|
153
|
+
...style,
|
|
154
|
+
}}
|
|
155
|
+
{...rest}
|
|
156
|
+
>
|
|
157
|
+
<Icon name={name} size={s.glyph} fill={fill} label={label} />
|
|
158
|
+
</span>
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export default FeaturedIcon;
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Icon: the one place Pathway renders an icon.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS FILE EXISTS. Every Figma icon component in this system had NO code
|
|
5
|
+
* to point at. Code Connect listed them with no path and no link, so a
|
|
6
|
+
* developer opening an icon in Figma was told nothing about how to render one.
|
|
7
|
+
* The repo was part of the reason: six components each carried their OWN
|
|
8
|
+
* private `Icon` or `Glyph` helper (top-nav, search, org-switcher, dashboard,
|
|
9
|
+
* kpi-tile, plus the sidenav's inline spans), all doing the same thing
|
|
10
|
+
* slightly differently, and none of them importable. There was nothing to
|
|
11
|
+
* link BECAUSE there was no shared module.
|
|
12
|
+
*
|
|
13
|
+
* WHAT A PATHWAY ICON ACTUALLY IS, since this is the question that keeps
|
|
14
|
+
* coming up. It is not an SVG file and there is no per-icon asset to fetch.
|
|
15
|
+
* It is one variable font, Material Symbols Rounded, and an icon is its NAME
|
|
16
|
+
* written as text inside an element that has the font applied. The browser's
|
|
17
|
+
* ligature substitution turns the text `arrow_forward` into the arrow glyph.
|
|
18
|
+
* So:
|
|
19
|
+
*
|
|
20
|
+
* the font Material Symbols Rounded, from Google Fonts
|
|
21
|
+
* the stylesheet https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded
|
|
22
|
+
* :opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200
|
|
23
|
+
* the catalogue https://fonts.google.com/icons (set Style = Rounded)
|
|
24
|
+
* the source https://github.com/google/material-design-icons
|
|
25
|
+
* one icon <span class="material-symbols-rounded">arrow_forward</span>
|
|
26
|
+
*
|
|
27
|
+
* The name you write is the folder name in that repo and the name shown in
|
|
28
|
+
* that catalogue. Nothing is imported per icon, which is the whole appeal: the
|
|
29
|
+
* font carries every glyph, so adding an icon costs nothing.
|
|
30
|
+
*
|
|
31
|
+
* THE FOUR AXES are variable-font axes, not separate fonts:
|
|
32
|
+
*
|
|
33
|
+
* FILL 0 outlined, 1 solid. READ IT FROM FIGMA PER COMPONENT, never assume.
|
|
34
|
+
* SideNav is FILL 1 throughout; the Widget header is FILL 0.
|
|
35
|
+
* wght 400 everywhere in Pathway.
|
|
36
|
+
* GRAD 0 everywhere in Pathway.
|
|
37
|
+
* opsz matched to the font size, which is what keeps the stroke weight even
|
|
38
|
+
* across sizes. A 16px icon at opsz 20 is visibly lighter than it
|
|
39
|
+
* should be, and that defect shipped once in the SideNav rail.
|
|
40
|
+
*
|
|
41
|
+
* SIZE IS THE FRAME, NEVER THE VECTOR. Figma nests a wrapper (24), an icon
|
|
42
|
+
* frame (16) and the drawn vector (~12), because the Material grid has about
|
|
43
|
+
* 2px of built-in padding. `size` here is the FRAME, so `size={16}` reproduces
|
|
44
|
+
* Figma's 16 and lands the visible glyph near 12. Passing the vector's 12
|
|
45
|
+
* ships an icon a third too small. See docs/design-system-spec.md §7.2.
|
|
46
|
+
*/
|
|
47
|
+
import React from "react";
|
|
48
|
+
|
|
49
|
+
/** The stylesheet every surface that renders an icon must load. */
|
|
50
|
+
export const MATERIAL_SYMBOLS_HREF =
|
|
51
|
+
"https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded" +
|
|
52
|
+
":opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200";
|
|
53
|
+
|
|
54
|
+
/** Where the names come from, for anyone looking for one. */
|
|
55
|
+
export const ICON_SOURCES = {
|
|
56
|
+
catalogue: "https://fonts.google.com/icons",
|
|
57
|
+
repo: "https://github.com/google/material-design-icons",
|
|
58
|
+
webSymbols: "https://github.com/google/material-design-icons/tree/master/symbols/web",
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** Pathway's fixed axis values. Only FILL and opsz ever vary. */
|
|
62
|
+
export const AXES = { wght: 400, GRAD: 0 };
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The per-size table from docs/design-system-spec.md §7.2. `wrapper` is the
|
|
66
|
+
* container Figma draws around the frame, and is the touch or alignment box
|
|
67
|
+
* rather than anything the glyph itself occupies.
|
|
68
|
+
*/
|
|
69
|
+
export const SIZES = {
|
|
70
|
+
L: { frame: 18, wrapper: 26 },
|
|
71
|
+
M: { frame: 16, wrapper: 24 },
|
|
72
|
+
S: { frame: 14, wrapper: 20 },
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @param {string} name The Material Symbols ligature, e.g. "arrow_forward".
|
|
77
|
+
* Same string as the folder name in Google's repo.
|
|
78
|
+
* @param {number|"L"|"M"|"S"} size The FRAME size. A letter resolves through
|
|
79
|
+
* SIZES; a number is used as-is. Default M, 16.
|
|
80
|
+
* @param {0|1} fill FILL axis. Read it from Figma; never assume.
|
|
81
|
+
* @param {string} color Any CSS colour. Pass a semantic token's var(), never
|
|
82
|
+
* a hex. Omit to inherit, which is usually right: an
|
|
83
|
+
* icon beside a label should take the label's colour.
|
|
84
|
+
* @param {string} label An accessible name. Omit for a decorative icon and it
|
|
85
|
+
* is hidden from assistive tech, which is the default
|
|
86
|
+
* because most icons sit beside their own text.
|
|
87
|
+
*/
|
|
88
|
+
export function Icon({
|
|
89
|
+
name,
|
|
90
|
+
size = "M",
|
|
91
|
+
fill = 0,
|
|
92
|
+
color,
|
|
93
|
+
label,
|
|
94
|
+
className = "",
|
|
95
|
+
style,
|
|
96
|
+
...rest
|
|
97
|
+
}) {
|
|
98
|
+
const frame = typeof size === "number" ? size : (SIZES[size] || SIZES.M).frame;
|
|
99
|
+
return (
|
|
100
|
+
<span
|
|
101
|
+
className={`material-symbols-rounded ${className}`}
|
|
102
|
+
// Decorative by default. A label makes it an image with a name.
|
|
103
|
+
role={label ? "img" : undefined}
|
|
104
|
+
aria-label={label || undefined}
|
|
105
|
+
aria-hidden={label ? undefined : true}
|
|
106
|
+
style={{
|
|
107
|
+
fontSize: frame,
|
|
108
|
+
lineHeight: 1,
|
|
109
|
+
display: "block",
|
|
110
|
+
flexShrink: 0,
|
|
111
|
+
color,
|
|
112
|
+
// opsz TRACKS THE FONT SIZE. Pinning it to 20 while the glyph renders
|
|
113
|
+
// at 16 makes the stroke too light, which is what went wrong in the
|
|
114
|
+
// collapsed SideNav rail.
|
|
115
|
+
fontVariationSettings: `'FILL' ${fill}, 'wght' ${AXES.wght}, 'GRAD' ${AXES.GRAD}, 'opsz' ${frame}`,
|
|
116
|
+
...style,
|
|
117
|
+
}}
|
|
118
|
+
{...rest}
|
|
119
|
+
>
|
|
120
|
+
{name}
|
|
121
|
+
</span>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The icon in its Figma wrapper: the alignment box, not the glyph.
|
|
127
|
+
*
|
|
128
|
+
* Use it when you are reproducing a Figma frame that nests
|
|
129
|
+
* `Container.LeadingIcon` around `Icon.Leading`. A bare `<Icon>` is the frame
|
|
130
|
+
* alone and is what most layouts want.
|
|
131
|
+
*/
|
|
132
|
+
export function IconBox({ name, size = "M", fill = 0, color, label, style, ...rest }) {
|
|
133
|
+
const s = typeof size === "string" ? (SIZES[size] || SIZES.M) : { frame: size, wrapper: size + 8 };
|
|
134
|
+
return (
|
|
135
|
+
<span style={{
|
|
136
|
+
display: "inline-flex", alignItems: "center", justifyContent: "center",
|
|
137
|
+
width: s.wrapper, height: s.wrapper, flexShrink: 0, ...style,
|
|
138
|
+
}} {...rest}>
|
|
139
|
+
<Icon name={name} size={s.frame} fill={fill} color={color} label={label} />
|
|
140
|
+
</span>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Drop the stylesheet in. Storybook and the HTML demos load it in their own
|
|
146
|
+
* head; this is for a consumer who renders an Icon without having done that,
|
|
147
|
+
* because a missing font shows the LIGATURE NAME as text rather than failing,
|
|
148
|
+
* and "arrow_forward" in the middle of a button is a confusing bug to chase.
|
|
149
|
+
*/
|
|
150
|
+
export const IconFont = () => (
|
|
151
|
+
<link rel="stylesheet" href={MATERIAL_SYMBOLS_HREF} />
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
export default Icon;
|